Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37fc2b8e66 | |||
| d434131d02 | |||
| b796e03bcb | |||
| e1b47e82b2 | |||
| 68ab79c713 | |||
| 29f4c4e48d | |||
| 934519767a | |||
| 9e224c0441 | |||
| a0b0febe85 | |||
| 5138d784cd | |||
| 5b019f942d | |||
| 14e783f111 | |||
| fb62227154 | |||
| 9b95be851c | |||
| 0f9458b7cb | |||
| 01b59e66ca | |||
| 9180783c42 |
@@ -15,7 +15,8 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
||||
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
|
||||
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
||||
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
|
||||
- **Use GitHub MCP for all GitHub remote operations. Never use `gh` CLI.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and releases must go through GitHub MCP tools only.
|
||||
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
|
||||
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
|
||||
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
||||
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
||||
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
||||
@@ -51,10 +52,11 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
||||
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
|
||||
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
|
||||
|
||||
## GitHub Operations (GitHub MCP Only)
|
||||
## GitHub Operations (GitHub MCP + gh CLI Exception)
|
||||
|
||||
- Never use `gh` CLI in this agent.
|
||||
- Use GitHub MCP tools for all GitHub actions: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, release publishing, and branch/PR metadata lookup.
|
||||
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
|
||||
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
|
||||
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
|
||||
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
|
||||
|
||||
## Workspace Hygiene And Source-Of-Truth Rules
|
||||
@@ -69,6 +71,7 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
||||
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
|
||||
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
|
||||
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
|
||||
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
|
||||
|
||||
---
|
||||
|
||||
@@ -399,7 +402,17 @@ Existing installations need to:
|
||||
|
||||
### Step 3: Publish
|
||||
|
||||
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish the release via GitHub MCP.
|
||||
Publish the release via `gh` CLI:
|
||||
|
||||
```bash
|
||||
# Write notes to a temp file first, then:
|
||||
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
|
||||
|
||||
# If the release was already auto-created (e.g. by pushing a tag), update it:
|
||||
gh release edit vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
|
||||
```
|
||||
|
||||
**Present the published release URL to the user for verification.**
|
||||
|
||||
---
|
||||
|
||||
@@ -452,6 +465,8 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
||||
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
|
||||
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
||||
|
||||
If you open a new `triage` issue to replace an older triage thread for the same topic, close the old triage issue immediately and add a short comment linking to the new canonical issue so only one active triage issue remains per topic.
|
||||
|
||||
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
||||
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "backend"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
@@ -28,7 +27,6 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "frontend"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
@@ -45,7 +43,6 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "root"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
@@ -62,7 +59,6 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
outputs:
|
||||
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-614%2F614-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/Backend_Tests-631%2F631-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-833%2F833-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -119,6 +119,12 @@ Share your medication schedule with others via a public link.
|
||||
</blockquote>
|
||||
</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
|
||||
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||
- Display remaining days of supply
|
||||
|
||||
Generated
+654
-394
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.20.1",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -32,17 +32,17 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fastify": "^5.8.2",
|
||||
"nodemailer": "^8.0.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@types/node": "^25.3.5",
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
|
||||
@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
|
||||
import type { Client } from "@libsql/client";
|
||||
import type { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import {
|
||||
forEachScheduledOccurrenceInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getScheduleMatchWindowMs,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -363,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||
|
||||
const validDates = new Set<number>();
|
||||
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
|
||||
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
|
||||
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||
});
|
||||
validDatesByIntake.set(idx, validDates);
|
||||
}
|
||||
|
||||
@@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
const intake = intakes[intakeIdx];
|
||||
if (!intake) continue;
|
||||
|
||||
const halfInterval = (intake.every * MS_PER_DAY) / 2;
|
||||
const halfInterval = getScheduleMatchWindowMs(intake);
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
@@ -29,6 +30,7 @@ import { reportRoutes } from "./routes/report.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment.js";
|
||||
import { startReminderScheduler } from "./services/reminder-scheduler.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: "auth", description: "Authentication and profile endpoints" },
|
||||
{ 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" },
|
||||
],
|
||||
components: {
|
||||
@@ -206,6 +209,7 @@ export async function createApp(options?: {
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -287,6 +291,7 @@ await app.register(authRoutes);
|
||||
await app.register(apiKeyRoutes);
|
||||
await app.register(oidcRoutes);
|
||||
await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(shareRoutes);
|
||||
@@ -307,6 +312,13 @@ const start = async () => {
|
||||
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)
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
app.log.info(`User registered: ${username}`);
|
||||
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
|
||||
|
||||
return reply.status(201).send({
|
||||
ok: true,
|
||||
@@ -376,7 +376,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
|
||||
);
|
||||
|
||||
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
|
||||
app.log.info(`[Auth] Login succeeded: username=${user.username}, userId=${user.id}, rememberMe=${rememberMe}`);
|
||||
|
||||
// Cookie options: with maxAge for "remember me", without for session cookie
|
||||
const accessCookieOptions = rememberMe
|
||||
@@ -807,7 +807,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// Delete user - cascade delete handles all related data
|
||||
await db.delete(users).where(eq(users.id, authUser.id));
|
||||
|
||||
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
|
||||
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
|
||||
|
||||
// Clear auth cookies
|
||||
return reply
|
||||
|
||||
+14
-15
@@ -61,11 +61,6 @@ const doseReadResponseSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) return token;
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -545,7 +540,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
|
||||
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
@@ -603,14 +598,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
|
||||
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -622,7 +617,9 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
|
||||
request.log.debug(
|
||||
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
@@ -634,7 +631,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
if (outOfStock) {
|
||||
request.log.info(
|
||||
`[ShareDose] Rejected out-of-stock mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
@@ -651,7 +648,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
@@ -685,14 +682,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
|
||||
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in unmark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -705,14 +702,16 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
|
||||
request.log.debug(
|
||||
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
await db
|
||||
.delete(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
request.log.info(
|
||||
`[ShareDose] Dose unmarked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.3";
|
||||
const EXPORT_VERSION = "1.4";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -33,6 +33,8 @@ const scheduleSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string(), // ISO datetime string
|
||||
scheduleMode: z.unknown().optional(),
|
||||
weekdays: z.unknown().optional(),
|
||||
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||
remind: z.boolean().optional().default(false),
|
||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||
@@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode: "interval" | "weekdays";
|
||||
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||
remind: boolean;
|
||||
takenBy: string | null;
|
||||
@@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||
usage: intake.usage,
|
||||
every: intake.every,
|
||||
start: intake.start,
|
||||
intakeUnit: null,
|
||||
scheduleMode: intake.scheduleMode ?? "interval",
|
||||
weekdays: intake.weekdays ?? [],
|
||||
intakeUnit: intake.intakeUnit ?? null,
|
||||
remind: intake.intakeRemindersEnabled,
|
||||
takenBy: intake.takenBy, // Per-intake takenBy
|
||||
}));
|
||||
@@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
// Convert schedules to both legacy and new formats
|
||||
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
||||
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
|
||||
// Build intakesJson array (new unified format with per-intake takenBy)
|
||||
const intakesJson = JSON.stringify(
|
||||
med.schedules.map((s) => ({
|
||||
usage: s.usage,
|
||||
every: s.every,
|
||||
start: s.start,
|
||||
intakeUnit: s.intakeUnit ?? null,
|
||||
takenBy: s.takenBy || null,
|
||||
intakeRemindersEnabled: s.remind ?? false,
|
||||
}))
|
||||
);
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(medications)
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../plugins/auth.js";
|
||||
import {
|
||||
enrichMedicationSelection,
|
||||
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
type MedicationEnrichmentEnrichRequest,
|
||||
MedicationEnrichmentServiceError,
|
||||
searchMedicationEnrichment,
|
||||
} from "../services/medication-enrichment.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1).max(120),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
|
||||
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
|
||||
});
|
||||
|
||||
const enrichBodySchema = z.object({
|
||||
query: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(140),
|
||||
genericName: z.string().trim().max(140).nullable().optional(),
|
||||
code: z.string().trim().min(1).max(160).nullable().optional(),
|
||||
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
|
||||
});
|
||||
|
||||
const searchQueryOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["q"],
|
||||
properties: {
|
||||
q: { type: "string", minLength: 1, maxLength: 120 },
|
||||
limit: {
|
||||
anyOf: [
|
||||
{ type: "string", pattern: "^[0-9]+$" },
|
||||
{
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
|
||||
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
required: ["query", "name"],
|
||||
properties: {
|
||||
query: { type: "string", minLength: 1, maxLength: 120 },
|
||||
name: { type: "string", minLength: 1, maxLength: 140 },
|
||||
genericName: { type: "string", nullable: true, maxLength: 140 },
|
||||
code: { type: "string", nullable: true, maxLength: 160 },
|
||||
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const strengthOptionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
pillWeightMg: { type: "number", nullable: true },
|
||||
doseUnit: {
|
||||
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const searchResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
normalizedQuery: { type: "string" },
|
||||
hasMore: { type: "boolean" },
|
||||
results: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
authorisationHolder: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
matchType: { type: "string", enum: ["brand", "ingredient"] },
|
||||
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
|
||||
authorisationDate: { type: "string", nullable: true },
|
||||
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const enrichResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
selection: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
therapeuticArea: { type: "string", nullable: true },
|
||||
indication: { type: "string", nullable: true },
|
||||
atcCode: { type: "string", nullable: true },
|
||||
source: {
|
||||
type: "string",
|
||||
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
genericName: { type: "string", nullable: true },
|
||||
medicationForm: {
|
||||
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
|
||||
},
|
||||
strengthOptions: { type: "array", items: strengthOptionSchema },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
type: "object",
|
||||
properties: {
|
||||
rxNormMatched: { type: "boolean" },
|
||||
openFdaMatched: { type: "boolean" },
|
||||
partial: { type: "boolean" },
|
||||
note: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function sendServiceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof MedicationEnrichmentServiceError) {
|
||||
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
|
||||
}
|
||||
|
||||
return reply.status(503).send({
|
||||
error: "Medication enrichment request failed.",
|
||||
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
|
||||
app.addHook("preHandler", requireAuth);
|
||||
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
|
||||
|
||||
app.get(
|
||||
"/medication-enrichment/search",
|
||||
{
|
||||
schema: {
|
||||
querystring: searchQueryOpenApiSchema,
|
||||
response: {
|
||||
200: searchResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = searchQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Search request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
|
||||
"/medication-enrichment/enrich",
|
||||
{
|
||||
schema: {
|
||||
body: enrichBodyOpenApiSchema,
|
||||
response: {
|
||||
200: enrichResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
503: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = enrichBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
try {
|
||||
return await enrichMedicationSelection(parsed.data, request.log);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
{
|
||||
code:
|
||||
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
|
||||
},
|
||||
"[MedicationEnrichment] Enrich request failed"
|
||||
);
|
||||
return sendServiceError(error, reply);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,13 @@ import {
|
||||
PACKAGE_TYPES,
|
||||
} from "../utils/package-profiles.js";
|
||||
import {
|
||||
countScheduledOccurrencesInRange,
|
||||
forEachScheduledOccurrenceInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getScheduleMatchWindowMs,
|
||||
type Intake,
|
||||
normalizeIntake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -100,6 +106,8 @@ const intakeSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string().datetime({ local: true }),
|
||||
scheduleMode: z.unknown().optional(),
|
||||
weekdays: z.unknown().optional(),
|
||||
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
||||
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
||||
@@ -274,6 +282,11 @@ const intakeOpenApiSchema = {
|
||||
usage: { type: "number", minimum: 0 },
|
||||
every: { type: "integer", minimum: 1 },
|
||||
start: { type: "string", description: "ISO datetime string; timezone suffix optional." },
|
||||
scheduleMode: { type: "string", enum: ["interval", "weekdays"] },
|
||||
weekdays: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] },
|
||||
},
|
||||
intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] },
|
||||
takenBy: { type: ["string", "null"], maxLength: 100 },
|
||||
intakeRemindersEnabled: { type: "boolean" },
|
||||
@@ -359,6 +372,8 @@ const medicationBodyOpenApiSchema = {
|
||||
usage: 1,
|
||||
every: 8,
|
||||
start: "2026-03-11T08:00:00.000Z",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
takenBy: "Daniel",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
@@ -664,25 +679,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
// Convert to unified intakes format
|
||||
let intakes: Intake[];
|
||||
if (inputIntakes) {
|
||||
// New format with per-intake takenBy
|
||||
intakes = inputIntakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
intakeUnit: i.intakeUnit ?? null,
|
||||
takenBy: i.takenBy || null,
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
|
||||
} else if (inputBlisters) {
|
||||
// Legacy format - convert to new format
|
||||
intakes = inputBlisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // No per-intake takenBy from legacy
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputBlisters.map((blister) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
intakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
|
||||
}
|
||||
@@ -840,25 +850,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
// Convert to unified intakes format
|
||||
let intakes: Intake[];
|
||||
if (inputIntakes) {
|
||||
// New format with per-intake takenBy
|
||||
intakes = inputIntakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
intakeUnit: i.intakeUnit ?? null,
|
||||
takenBy: i.takenBy || null,
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
|
||||
} else if (inputBlisters) {
|
||||
// Legacy format - convert to new format
|
||||
intakes = inputBlisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // No per-intake takenBy from legacy
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputBlisters.map((blister) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
intakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
|
||||
}
|
||||
@@ -942,8 +947,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
if (allDoses.length > 0) {
|
||||
// Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs
|
||||
const now = new Date();
|
||||
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
|
||||
for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) {
|
||||
const oldIntake = oldIntakes[idx];
|
||||
@@ -954,44 +958,45 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
const oldStart = parseLocalDateTime(oldIntake.start);
|
||||
const newStart = parseLocalDateTime(newIntake.start);
|
||||
const oldEvery = oldIntake.every;
|
||||
const newEvery = newIntake.every;
|
||||
|
||||
// Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs)
|
||||
// Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs)
|
||||
const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime();
|
||||
const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime();
|
||||
|
||||
if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) {
|
||||
const scheduleUnchanged =
|
||||
oldStartDateOnly === newStartDateOnly &&
|
||||
oldIntake.every === newIntake.every &&
|
||||
oldIntake.scheduleMode === newIntake.scheduleMode &&
|
||||
(oldIntake.weekdays ?? []).join(",") === (newIntake.weekdays ?? []).join(",");
|
||||
|
||||
if (scheduleUnchanged) {
|
||||
continue; // No schedule change that affects dose IDs
|
||||
}
|
||||
|
||||
// Build set of new valid dateOnlyMs values for this intake
|
||||
const newDates = new Set<number>();
|
||||
for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) {
|
||||
newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
|
||||
newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||
});
|
||||
|
||||
// Build set of old dateOnlyMs values with mapping to nearest new date
|
||||
const oldToNewMap = new Map<number, number>();
|
||||
for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) {
|
||||
const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
// Find the closest new date within ±(newEvery/2) days
|
||||
const halfInterval = (newEvery * MS_PER_DAY) / 2;
|
||||
const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake);
|
||||
forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
|
||||
const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs));
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
let bestDistance = Infinity;
|
||||
for (const newDateMs of newDates) {
|
||||
const dist = Math.abs(newDateMs - oldDateMs);
|
||||
if (dist < bestDist && dist <= halfInterval) {
|
||||
bestDist = dist;
|
||||
const distance = Math.abs(newDateMs - oldDateMs);
|
||||
if (distance < bestDistance && distance <= scheduleMatchWindowMs) {
|
||||
bestDistance = distance;
|
||||
bestMatch = newDateMs;
|
||||
}
|
||||
}
|
||||
if (bestMatch !== null && bestMatch !== oldDateMs) {
|
||||
oldToNewMap.set(oldDateMs, bestMatch);
|
||||
// Remove matched new date to prevent double-mapping
|
||||
newDates.delete(bestMatch);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply migrations to dose tracking entries
|
||||
if (oldToNewMap.size > 0) {
|
||||
@@ -1503,6 +1508,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
scheduleMode: i.scheduleMode,
|
||||
weekdays: i.weekdays,
|
||||
}));
|
||||
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
||||
const packCount = row.packCount ?? 1;
|
||||
@@ -1523,8 +1530,6 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
if (isTopical) {
|
||||
consumedUntilNow = 0;
|
||||
} else if (stockCalculationMode === "automatic") {
|
||||
@@ -1532,16 +1537,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
|
||||
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
|
||||
: blisterStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
@@ -1559,25 +1559,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
blister,
|
||||
effectiveStart,
|
||||
now.getTime()
|
||||
);
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
@@ -1768,34 +1763,19 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
function calculateUsageInRange(
|
||||
blisters: Array<{ usage: number; every: number; start: string }>,
|
||||
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
|
||||
start: Date,
|
||||
end: Date
|
||||
) {
|
||||
if (end.getTime() <= start.getTime()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const msPerDay = 86400000;
|
||||
blisters.forEach((blister) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
|
||||
const every = Math.max(1, blister.every);
|
||||
|
||||
// Skip ahead to the first occurrence at or after start to avoid
|
||||
// iterating through months/years of past doses
|
||||
const dt = new Date(blisterStart);
|
||||
if (dt < start) {
|
||||
const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay));
|
||||
dt.setDate(dt.getDate() + daysToSkip * every);
|
||||
// Fine-tune: advance until we reach or pass start
|
||||
while (dt < start) {
|
||||
dt.setDate(dt.getDate() + every);
|
||||
}
|
||||
}
|
||||
|
||||
// Count occurrences in [start, end)
|
||||
for (; dt < end; dt.setDate(dt.getDate() + every)) {
|
||||
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
|
||||
total += blister.usage;
|
||||
}
|
||||
});
|
||||
});
|
||||
return Number(total.toFixed(2));
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
// Set cookies (use app's centralized cookie options)
|
||||
request.log.debug(
|
||||
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
|
||||
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
|
||||
);
|
||||
setAuthCookies(app, reply, accessToken, refreshToken);
|
||||
|
||||
|
||||
@@ -40,13 +40,6 @@ function escapeHtml(text: string): string {
|
||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||
}
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [localPart, domain] = email.split("@");
|
||||
if (!domain) return "invalid-email";
|
||||
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||
}
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
@@ -256,10 +249,7 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||
request.log.info(
|
||||
{ hasEmail: Boolean(email), rowCount: rows?.length ?? 0 },
|
||||
"[Planner] Demand notification request received"
|
||||
);
|
||||
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing planner data" });
|
||||
@@ -277,6 +267,7 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
request.log.warn("[Planner] Demand notification skipped: no active medications in request");
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
const activeMedicationNames = activeRows.map((row) => row.medicationName);
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
@@ -291,6 +282,8 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||
activeRowCount: activeRows.length,
|
||||
recipientEmail: email,
|
||||
medications: activeMedicationNames,
|
||||
},
|
||||
"[Planner] Demand notification channel state"
|
||||
);
|
||||
@@ -377,13 +370,14 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[Planner] Demand email path selected"
|
||||
);
|
||||
@@ -494,7 +488,7 @@ ${getFooterPlain(language)}`;
|
||||
},
|
||||
});
|
||||
|
||||
request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email");
|
||||
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
@@ -509,19 +503,23 @@ ${getFooterPlain(language)}`;
|
||||
throw new Error(deliveryError);
|
||||
}
|
||||
|
||||
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent");
|
||||
request.log.info(
|
||||
{ userId, recipientEmail: email, messageId: mailResult.messageId },
|
||||
"[Planner] Demand email sent"
|
||||
);
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed");
|
||||
request.log.error({ userId, recipientEmail: email, error }, "[Planner] Demand email failed");
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
request.log.warn(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[Planner] Demand email skipped: SMTP not configured"
|
||||
);
|
||||
@@ -612,7 +610,7 @@ ${getFooterPlain(language)}`;
|
||||
async (request, reply) => {
|
||||
const { email, lowStock } = request.body;
|
||||
request.log.info(
|
||||
{ hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 },
|
||||
{ email, lowStockCount: lowStock?.length ?? 0 },
|
||||
"[ReminderManual] Stock reminder request received"
|
||||
);
|
||||
|
||||
@@ -641,6 +639,7 @@ ${getFooterPlain(language)}`;
|
||||
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
const filteredMedicationNames = filteredLowStock.map((item) => item.name);
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
@@ -655,6 +654,8 @@ ${getFooterPlain(language)}`;
|
||||
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||
filteredLowStockCount: filteredLowStock.length,
|
||||
recipientEmail: email,
|
||||
medications: filteredMedicationNames,
|
||||
},
|
||||
"[ReminderManual] Stock reminder channel state"
|
||||
);
|
||||
@@ -731,13 +732,14 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Stock email path selected"
|
||||
);
|
||||
@@ -855,7 +857,7 @@ ${getFooterPlain(language)}`;
|
||||
},
|
||||
});
|
||||
|
||||
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email");
|
||||
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending stock reminder email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
@@ -871,21 +873,22 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||
{ userId, recipientEmail: email, messageId: mailResult.messageId },
|
||||
"[ReminderManual] Stock reminder email sent"
|
||||
);
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed");
|
||||
request.log.error({ userId, recipientEmail: email, error }, "[ReminderManual] Stock reminder email failed");
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
request.log.warn(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
||||
);
|
||||
@@ -983,7 +986,7 @@ ${getFooterPlain(language)}`;
|
||||
async (request, reply) => {
|
||||
const { email, prescriptionLow } = request.body;
|
||||
request.log.info(
|
||||
{ hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 },
|
||||
{ email, prescriptionCount: prescriptionLow?.length ?? 0 },
|
||||
"[ReminderManual] Prescription reminder request received"
|
||||
);
|
||||
|
||||
@@ -1002,10 +1005,23 @@ ${getFooterPlain(language)}`;
|
||||
request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering");
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
const filteredMedicationNames = filteredPrescriptionLow.map((item) => item.name);
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
pushEnabled: userSettings.shoutrrrEnabled,
|
||||
hasPushUrl: Boolean(userSettings.shoutrrrUrl),
|
||||
prescriptionCount: filteredPrescriptionLow.length,
|
||||
recipientEmail: email,
|
||||
medications: filteredMedicationNames,
|
||||
},
|
||||
"[ReminderManual] Prescription reminder channel state"
|
||||
);
|
||||
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
@@ -1039,13 +1055,14 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Prescription email path selected"
|
||||
);
|
||||
@@ -1133,7 +1150,7 @@ ${getFooterPlain(language)}`;
|
||||
</div>
|
||||
`;
|
||||
|
||||
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email");
|
||||
request.log.info({ userId, recipientEmail: email }, "[ReminderManual] Sending prescription reminder email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
@@ -1149,21 +1166,25 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||
{ userId, recipientEmail: email, messageId: mailResult.messageId },
|
||||
"[ReminderManual] Prescription reminder email sent"
|
||||
);
|
||||
results.email = true;
|
||||
} catch (error) {
|
||||
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed");
|
||||
request.log.error(
|
||||
{ userId, recipientEmail: email, error },
|
||||
"[ReminderManual] Prescription reminder email failed"
|
||||
);
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
results.errors.push(`Email: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
request.log.warn(
|
||||
{
|
||||
userId,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
to: maskEmail(email),
|
||||
recipientEmail: email,
|
||||
},
|
||||
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
||||
);
|
||||
|
||||
@@ -97,13 +97,6 @@ const settingsErrorSchema = {
|
||||
},
|
||||
};
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [localPart, domain] = email.split("@");
|
||||
if (!domain) return "invalid-email";
|
||||
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||
}
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
@@ -668,7 +661,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
to: maskEmail(email),
|
||||
to: email,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
@@ -681,7 +674,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
request.log.warn(
|
||||
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
||||
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
||||
"[Settings] Test email skipped: SMTP not configured"
|
||||
);
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
@@ -698,7 +691,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
|
||||
request.log.info({ to: email }, "[Settings] Sending test email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
@@ -721,11 +714,11 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
throw new Error(deliveryError);
|
||||
}
|
||||
|
||||
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||
|
||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||
} catch (error) {
|
||||
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
|
||||
request.log.error({ to: email, error }, "[Settings] Test email failed");
|
||||
const failure = classifyTestEmailFailure(error);
|
||||
return reply.status(failure.status).send({ error: failure.message, code: failure.code });
|
||||
}
|
||||
|
||||
+20
-16
@@ -96,11 +96,6 @@ const shareOverviewResponseSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) return token;
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -155,7 +150,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
|
||||
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -165,7 +160,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
@@ -186,7 +181,10 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// Use SQLite JSON function to check if takenBy is in the array
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
|
||||
|
||||
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
|
||||
const meds = allMeds.filter((med) => {
|
||||
@@ -301,19 +299,19 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
|
||||
const { token } = request.params;
|
||||
if (!shareTokenPattern.test(token)) {
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: ${maskToken(token)}`);
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: ${maskToken(token)}`);
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[ShareOverview] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
return reply.status(410).send({
|
||||
error: "expired",
|
||||
@@ -324,7 +322,10 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -392,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
|
||||
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
||||
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
const allMeds = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const medsForPerson = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -421,7 +425,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -443,7 +447,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -490,7 +494,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
|
||||
// Collect all unique person names from medication-level AND intake-level takenBy
|
||||
const allPeople = new Set<string>();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
getAverageOccurrencesPerDay,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getTodayInTimezone,
|
||||
type Intake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
@@ -20,6 +20,8 @@ export type SharedMedicationOverviewItem = {
|
||||
imageUrl: string | null;
|
||||
packageType: string;
|
||||
packCount: number;
|
||||
packageAmountValue: number | null;
|
||||
packageAmountUnit: "ml" | "g" | null;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
totalPills: number | null;
|
||||
@@ -58,35 +60,27 @@ function computeCapacity(medication: MedicationRow): number {
|
||||
|
||||
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
|
||||
return intakes.reduce((sum, intake) => {
|
||||
if (intake.every <= 0) return sum;
|
||||
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
return sum + normalizedUsage / intake.every;
|
||||
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
||||
const today = parseDateOnly(todayDateOnly);
|
||||
let nextDate: Date | null = null;
|
||||
let nextOccurrenceMs: number | null = null;
|
||||
|
||||
for (const intake of intakes) {
|
||||
if (intake.every <= 0) continue;
|
||||
|
||||
const startDate = parseLocalDateTime(intake.start);
|
||||
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
|
||||
|
||||
let candidate = startDateOnly;
|
||||
if (candidate.getTime() < today.getTime()) {
|
||||
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
|
||||
const intervals = Math.ceil(elapsedDays / intake.every);
|
||||
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
|
||||
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
|
||||
if (occurrenceMs === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
|
||||
nextDate = candidate;
|
||||
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
|
||||
nextOccurrenceMs = occurrenceMs;
|
||||
}
|
||||
}
|
||||
|
||||
return nextDate ? toDateOnlyString(nextDate) : null;
|
||||
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
|
||||
}
|
||||
|
||||
function computeTakenAmount(
|
||||
@@ -186,7 +180,7 @@ export function buildSharedMedicationOverview(options: {
|
||||
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
|
||||
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
|
||||
const depletionDate =
|
||||
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
|
||||
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
|
||||
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
||||
return {
|
||||
name: medication.name,
|
||||
@@ -194,6 +188,11 @@ export function buildSharedMedicationOverview(options: {
|
||||
imageUrl: medication.imageUrl,
|
||||
packageType: medication.packageType,
|
||||
packCount: medication.packCount,
|
||||
packageAmountValue: medication.packageAmountValue,
|
||||
packageAmountUnit:
|
||||
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
|
||||
? medication.packageAmountUnit
|
||||
: null,
|
||||
blistersPerPack: medication.blistersPerPack,
|
||||
pillsPerBlister: medication.pillsPerBlister,
|
||||
totalPills: medication.totalPills,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
countScheduledOccurrencesInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getNextScheduledOccurrenceTime,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -10,7 +13,6 @@ import {
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
type DoseRow = typeof doseTracking.$inferSelect;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getDoseTakenAtMs(dose: DoseRow): number {
|
||||
@@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: {
|
||||
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
||||
if (Number.isNaN(intakeStart)) return;
|
||||
|
||||
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = intakeStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
|
||||
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
|
||||
: intakeStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intake.takenBy) {
|
||||
@@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: {
|
||||
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
if (effectiveStart <= nowMs) {
|
||||
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
intake,
|
||||
effectiveStart,
|
||||
nowMs
|
||||
);
|
||||
consumed += occurrences * usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
|
||||
@@ -91,19 +91,25 @@ function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; b
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
}
|
||||
|
||||
async function resolveSchedulerUserDisplayName(userId: number): Promise<string> {
|
||||
const [userRow] = await db.select({ username: users.username }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
return userRow?.username?.trim() || `unknown-user-${userId}`;
|
||||
async function getUsernameForLog(userId: number): Promise<string> {
|
||||
const user = await db.select({ username: users.username }).from(users).where(eq(users.id, userId));
|
||||
const username = user[0]?.username?.trim();
|
||||
return username && username.length > 0 ? username : `unknown-user-${userId}`;
|
||||
}
|
||||
|
||||
function formatIntakeDescriptor(
|
||||
definitionIndex: number,
|
||||
medicationName: string,
|
||||
medicationId: number,
|
||||
intake: { every: number; usage: number; start: string; intakeRemindersEnabled: boolean; takenBy: string | null }
|
||||
): string {
|
||||
const takenByPart = intake.takenBy ? `, takenBy=${intake.takenBy}` : "";
|
||||
return `Intake #${definitionIndex + 1} (index=${definitionIndex}, medication=${medicationName}, medicationId=${medicationId}, start=${intake.start}, every=${intake.every}d, usage=${intake.usage}, reminderEnabled=${intake.intakeRemindersEnabled}${takenByPart})`;
|
||||
function formatIntakeLog(intake: {
|
||||
medName: string;
|
||||
medicationId: number;
|
||||
blisterIndex: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
usage: number;
|
||||
doseUnit?: string;
|
||||
takenBy?: string | null;
|
||||
}): string {
|
||||
const takenBy = intake.takenBy ? intake.takenBy : "none";
|
||||
const doseUnit = intake.doseUnit ?? "mg";
|
||||
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
|
||||
}
|
||||
|
||||
async function autoMarkDueIntakesAsTaken(
|
||||
@@ -114,6 +120,9 @@ async function autoMarkDueIntakesAsTaken(
|
||||
logger: ServiceLogger
|
||||
): Promise<number> {
|
||||
if (settings.stockCalculationMode !== "automatic") {
|
||||
logger.debug(
|
||||
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -214,6 +223,19 @@ async function autoMarkDueIntakesAsTaken(
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Auto-marked intake for userId=${settings.userId}: ${formatIntakeLog({
|
||||
medName: intake.medName,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
intakeTime: intake.intakeTime,
|
||||
intakeTimeStr: intake.intakeTimeStr,
|
||||
usage: intake.usage,
|
||||
doseUnit: intake.doseUnit,
|
||||
takenBy: intake.takenBy,
|
||||
})}`
|
||||
);
|
||||
|
||||
existingDoseIds.add(doseId);
|
||||
trackedDoses.push({
|
||||
id: 0,
|
||||
@@ -229,8 +251,10 @@ async function autoMarkDueIntakesAsTaken(
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
logger.info(`[IntakeReminder] Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||
if (inserted === 0) {
|
||||
logger.debug(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: no due intakes`);
|
||||
} else {
|
||||
logger.info(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: inserted=${inserted}`);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
@@ -417,55 +441,70 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
logger.debug(`[IntakeReminder] Scheduler cycle loaded user settings count=${allUserSettings.length}`);
|
||||
|
||||
if (allUserSettings.length === 0) {
|
||||
logger.debug(`[IntakeReminder] No users with settings found`);
|
||||
return; // No users with settings
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Evaluating ${allUserSettings.length} intake profile(s) for auto-marking`);
|
||||
|
||||
for (const userSettings of allUserSettings) {
|
||||
await checkAndSendIntakeRemindersForUser(userSettings, logger);
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Scheduler cycle finished`);
|
||||
}
|
||||
|
||||
export async function checkAndSendIntakeRemindersForUser(
|
||||
settings: UserSettings & { userId: number },
|
||||
logger: ServiceLogger
|
||||
): Promise<void> {
|
||||
const username = await getUsernameForLog(settings.userId);
|
||||
logger.info(
|
||||
`[IntakeReminder] Evaluating intake reminders for user=${username} (userId=${settings.userId}, emailEnabled=${settings.emailEnabled}, pushEnabled=${settings.shoutrrrEnabled}, skipTaken=${settings.skipRemindersForTakenDoses}, repeat=${settings.repeatRemindersEnabled}, mode=${settings.stockCalculationMode})`
|
||||
);
|
||||
|
||||
const language = settings.language;
|
||||
const tr = getTranslations(language);
|
||||
const schedulerUserName = await resolveSchedulerUserDisplayName(settings.userId);
|
||||
|
||||
logger.debug(`[IntakeReminder] Evaluating intake reminder profile for user '${schedulerUserName}'`);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
.where(and(eq(medications.userId, settings.userId), eq(medications.isObsolete, false)));
|
||||
|
||||
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
||||
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||
if (autoMarkedCount > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Auto-mark summary for user=${username} (userId=${settings.userId}): autoMarkedCount=${autoMarkedCount}`
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.stockCalculationMode === "automatic" && settings.skipRemindersForTakenDoses) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminder sending skipped for user=${username} (userId=${settings.userId}) because stockCalculationMode=automatic and skipRemindersForTakenDoses=true`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any intake reminder notifications are enabled (granular check)
|
||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||
|
||||
if (!emailEnabled && !shoutrrrEnabled) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] Notification sending disabled for user=${username} (userId=${settings.userId}): both email and push intake reminders are off`
|
||||
);
|
||||
return; // No intake reminder notifications enabled for this user
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] Notifications enabled for current scheduler context (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||
);
|
||||
|
||||
// Build medication entries that have at least one reminder-enabled intake.
|
||||
// Intake-level reminders are the single source of truth.
|
||||
const reminderEntries = rows
|
||||
const reminderEntries = activeRows
|
||||
.map((med) => {
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
@@ -478,42 +517,32 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
.filter((entry) => entry.intakesWithReminders.length > 0);
|
||||
|
||||
if (reminderEntries.length === 0) {
|
||||
logger.debug("[IntakeReminder] No medications have reminders enabled for current scheduler context");
|
||||
logger.debug(
|
||||
`[IntakeReminder] No reminder-enabled intake definitions for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
return; // No medications have reminders enabled for this user
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Found ${reminderEntries.length} medications with reminders`);
|
||||
|
||||
const state = loadIntakeReminderState();
|
||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
let scheduledIntakesTodayCount = 0;
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const checkMinuteStart = new Date(Math.floor(now.getTime() / 60000) * 60000);
|
||||
const checkMinuteEnd = new Date(checkMinuteStart.getTime() + 60000);
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
logger.debug(`[IntakeReminder] Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`);
|
||||
|
||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||
// Medication-level takenBy (for fallback/display purposes)
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = med.name || med.genericName || "";
|
||||
|
||||
logger.debug(
|
||||
`[IntakeReminder] Processing medication '${medDisplayName}' (id=${med.id}) with ${intakes.length} intake definition(s)`
|
||||
);
|
||||
|
||||
// Process each intake separately to track blisterIndex
|
||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||
const intakeDescriptor = formatIntakeDescriptor(actualIndex, medDisplayName, med.id, intake);
|
||||
logger.debug(`[IntakeReminder] ${intakeDescriptor}`);
|
||||
|
||||
const todaysIntakesForThisDefinition = getTodaysIntakes(
|
||||
medDisplayName,
|
||||
@@ -540,12 +569,6 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] ${intakeDescriptor} -> ${upcomingIntakes.length} intake(s) currently due for advance reminder (default ${REMINDER_MINUTES_BEFORE} min before intake, with catch-up while intake is still in the future)`
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} scheduled intake(s) today (independent of reminder window)`
|
||||
);
|
||||
|
||||
// Add upcoming intakes for first reminders
|
||||
allUpcoming.push(
|
||||
@@ -558,15 +581,9 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||
if (settings.repeatRemindersEnabled) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] ${intakeDescriptor} -> ${todaysIntakesForThisDefinition.length} candidate intake(s) for repeat reminders`
|
||||
);
|
||||
const missedIntakes = todaysIntakesForThisDefinition.filter(
|
||||
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
|
||||
);
|
||||
logger.debug(
|
||||
`[IntakeReminder] ${intakeDescriptor} -> ${missedIntakes.length} missed intake(s) (past intake time)`
|
||||
);
|
||||
|
||||
// Add missed intakes for repeat reminders (only if not already in upcoming list)
|
||||
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
|
||||
@@ -583,16 +600,17 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`[IntakeReminder] Total scheduled intakes for today: ${scheduledIntakesTodayCount}`);
|
||||
logger.debug(`[IntakeReminder] Total reminder candidates in current check: ${allUpcoming.length}`);
|
||||
|
||||
if (allUpcoming.length === 0) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] No reminder due in this check window (minute=${checkMinuteStart.toISOString()}..${checkMinuteEnd.toISOString()}, advanceLead=${REMINDER_MINUTES_BEFORE}m, plus catch-up while intake is still future)`
|
||||
`[IntakeReminder] No upcoming intakes in reminder window for user=${username} (userId=${settings.userId}, scheduledToday=${scheduledIntakesTodayCount})`
|
||||
);
|
||||
return; // No upcoming intakes for today
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Candidate intakes for user=${username} (userId=${settings.userId}): scheduledToday=${scheduledIntakesTodayCount}, candidates=${allUpcoming.length}`
|
||||
);
|
||||
|
||||
// Determine which doses need reminders (new or repeated)
|
||||
const nowMs = Date.now();
|
||||
const maxReminders = settings.maxNaggingReminders ?? 5;
|
||||
@@ -620,9 +638,6 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
// Recently missed — scheduler likely recovered from sleep/restart.
|
||||
// Send a catch-up reminder (counts as first nagging reminder).
|
||||
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
|
||||
logger.info(
|
||||
`[IntakeReminder] Catch-up reminder for recently missed intake (${Math.round(minutesSinceIntake)} min ago)`
|
||||
);
|
||||
} else {
|
||||
// Long ago — seed state without notification (user likely already noticed)
|
||||
state.reminders[key] = {
|
||||
@@ -631,14 +646,10 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
sendCount: 0,
|
||||
advanceSent: false,
|
||||
};
|
||||
logger.debug(
|
||||
`[IntakeReminder] Seeding state for old past intake (no notification — ${Math.round(minutesSinceIntake)} min ago)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Upcoming - this is advance reminder (no counter)
|
||||
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
|
||||
logger.debug("[IntakeReminder] Advance reminder candidate added");
|
||||
}
|
||||
} else if (settings.repeatRemindersEnabled && isIntakePast) {
|
||||
// Intake time passed - check if we need to send nagging reminder
|
||||
@@ -650,23 +661,41 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
const currentNaggingCount = existingEntry.sendCount;
|
||||
|
||||
if (currentNaggingCount >= maxReminders) {
|
||||
// Max nagging reminders reached - stop
|
||||
logger.debug(`[IntakeReminder] Max nagging (${maxReminders}) reached for intake reminder key`);
|
||||
} else if (timeSinceLastReminder >= intervalMs) {
|
||||
const nextSendCount = currentNaggingCount + 1;
|
||||
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
|
||||
logger.debug(`[IntakeReminder] Nagging reminder candidate added (${nextSendCount}/${maxReminders})`);
|
||||
}
|
||||
}
|
||||
// Else: Already sent and either repeats disabled or intake not yet past - skip
|
||||
}
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.debug(
|
||||
`[IntakeReminder] No reminders to send for user=${username} (userId=${settings.userId}) after state/repeat evaluation`
|
||||
);
|
||||
return; // All reminders already sent and no repeats needed
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminders selected for user=${username} (userId=${settings.userId}): count=${remindersToSend.length} :: ${remindersToSend
|
||||
.map((intake) =>
|
||||
formatIntakeLog({
|
||||
medName: intake.medName,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
intakeTime: intake.intakeTime,
|
||||
intakeTimeStr: intake.intakeTimeStr,
|
||||
usage: intake.usage,
|
||||
doseUnit: intake.doseUnit,
|
||||
takenBy: intake.takenBy,
|
||||
})
|
||||
)
|
||||
.join(" | ")}`
|
||||
);
|
||||
|
||||
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
|
||||
if (settings.skipRemindersForTakenDoses) {
|
||||
const beforeFilterCount = remindersToSend.length;
|
||||
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
|
||||
const takenToday = await db
|
||||
.select()
|
||||
@@ -692,29 +721,30 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
// For person-specific intake, check if that person has taken it
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||
const isTaken = takenDoseIds.has(doseId);
|
||||
if (isTaken) {
|
||||
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
|
||||
}
|
||||
return !isTaken;
|
||||
} else {
|
||||
// For non-person-specific intakes
|
||||
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
const isTaken = takenDoseIds.has(doseId);
|
||||
if (isTaken) {
|
||||
logger.debug("[IntakeReminder] Skipping reminder candidate - dose already taken");
|
||||
}
|
||||
return !isTaken;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredOutCount = beforeFilterCount - remindersToSend.length;
|
||||
if (filteredOutCount > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Removed reminders for already taken doses for user=${username} (userId=${settings.userId}): removed=${filteredOutCount}, remaining=${remindersToSend.length}`
|
||||
);
|
||||
}
|
||||
|
||||
if (remindersToSend.length === 0) {
|
||||
logger.debug("[IntakeReminder] All doses taken, skipping reminders");
|
||||
logger.info(
|
||||
`[IntakeReminder] All candidate reminders already taken for user=${username} (userId=${settings.userId}); nothing to send`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[IntakeReminder] Sending reminder for ${remindersToSend.length} intakes...`);
|
||||
|
||||
// Determine if this is a repeat reminder:
|
||||
// - Any intake already has a state entry AND is past (repeat after first reminder)
|
||||
// - OR intake is past even without state entry (missed the 15-min window)
|
||||
@@ -744,10 +774,14 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
hasNaggingReminder ? maxReminderCount : undefined
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info("[IntakeReminder] Email sent successfully");
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Email delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`[IntakeReminder] Failed to send email: ${result.error}`);
|
||||
logger.info(
|
||||
`[IntakeReminder] Email delivered for user=${username} (userId=${settings.userId}, recipient=${settings.notificationEmail}, reminders=${remindersToSend.length}, messageId=${result.messageId ?? "n/a"})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,10 +844,14 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (result.success) {
|
||||
logger.info("[IntakeReminder] Push notification sent successfully");
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`[IntakeReminder] Failed to send push: ${result.error}`);
|
||||
logger.info(
|
||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -880,6 +918,13 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
const medName = firstReminder?.medName;
|
||||
const takenBy = firstReminder?.takenBy || undefined;
|
||||
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
|
||||
logger.info(
|
||||
`[IntakeReminder] Reminder state persisted for user=${username} (userId=${settings.userId}, channel=${channel}, reminders=${remindersToSend.length}, firstMed=${medName ?? "n/a"}, firstTakenBy=${takenBy ?? "none"})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[IntakeReminder] No reminder channel succeeded for user=${username} (userId=${settings.userId}, remindersAttempted=${remindersToSend.length})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,13 @@ import {
|
||||
import {
|
||||
type Blister,
|
||||
calculateDepletionInfo,
|
||||
countScheduledOccurrencesInRange,
|
||||
createDefaultReminderState,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getDateOnlyTimestamp,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
@@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const packageType = normalizePackageType(row.packageType);
|
||||
@@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder(
|
||||
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
scheduleMode: i.scheduleMode,
|
||||
weekdays: i.weekdays,
|
||||
}));
|
||||
|
||||
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||
@@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder(
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
|
||||
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
|
||||
: blisterStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
@@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder(
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
blister,
|
||||
effectiveStart,
|
||||
now
|
||||
);
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
|
||||
@@ -539,6 +539,14 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
it("should return shared medication overview for a valid token", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, taken_by_json, package_type, pack_count, blisters_per_pack, pills_per_blister,
|
||||
package_amount_value, package_amount_unit, total_pills, loose_tablets, medication_form,
|
||||
usage_json, every_json, start_json
|
||||
) VALUES (?, ?, ?, 'tube', 2, 1, 1, 40, 'g', 80, 80, 'topical', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [userId, "Hydrogel", JSON.stringify(["Daniel"])],
|
||||
});
|
||||
const token = "abcdef0123456789";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
@@ -554,9 +562,17 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.takenBy).toBe("Daniel");
|
||||
expect(data.sharedBy).toBe("__anonymous__");
|
||||
expect(Array.isArray(data.medications)).toBe(true);
|
||||
expect(data.medications).toHaveLength(1);
|
||||
expect(data.medications).toHaveLength(2);
|
||||
expect(data.medications[0].name).toBe("Aspirin");
|
||||
expect(data.medications[0].currentStock).toBeTypeOf("number");
|
||||
const hydrogel = data.medications.find((med: { name: string }) => med.name === "Hydrogel");
|
||||
expect(hydrogel).toMatchObject({
|
||||
packageType: "tube",
|
||||
packCount: 2,
|
||||
packageAmountValue: 40,
|
||||
packageAmountUnit: "g",
|
||||
totalPills: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 404 for unknown overview token", async () => {
|
||||
|
||||
@@ -18,6 +18,14 @@ function createLogger() {
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectWhere<T>(result: T) {
|
||||
return {
|
||||
from: () => ({
|
||||
where: async () => result,
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
const mockedDb = vi.mocked(db);
|
||||
let originalTz: string | undefined;
|
||||
@@ -45,73 +53,43 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ username: "auto-user" }],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
);
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
@@ -148,7 +126,7 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-mark completed for userId=11: inserted=1");
|
||||
});
|
||||
|
||||
it("does not auto-mark due intakes when current stock is empty", async () => {
|
||||
@@ -157,73 +135,43 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ username: "auto-user" }],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
);
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
@@ -255,4 +203,81 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||
});
|
||||
|
||||
it("suppresses intake notifications entirely when automatic mode and skip-taken reminders are both enabled", async () => {
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "test-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]))
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
insertedRows.push(row);
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
skipRemindersForTakenDoses: true,
|
||||
emailEnabled: true,
|
||||
notificationEmail: "user@example.com",
|
||||
emailIntakeReminders: true,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrIntakeReminders: false,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Sending reminder for 1 intakes...");
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -942,17 +942,17 @@ describe("Integration Tests", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Planner usage calculation", () => {
|
||||
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
|
||||
const futureDailyStart = "2030-01-15T08:00:00.000Z";
|
||||
const futureEveningStart = "2030-01-15T20:00:00.000Z";
|
||||
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
|
||||
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
|
||||
|
||||
it("should calculate correct usage for daily medication", async () => {
|
||||
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow (future date)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
// This avoids daylight-saving-time edge cases in local test environments.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -972,8 +972,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr, // 10 days
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -988,15 +988,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should detect insufficient stock", async () => {
|
||||
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1016,8 +1009,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1029,15 +1022,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate weekly medication usage correctly", async () => {
|
||||
// Create medication: 10 pills total
|
||||
// Schedule: 1 pill every 7 days starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1056,8 +1042,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: thirtyFiveDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1070,18 +1056,8 @@ describe("Integration Tests", () => {
|
||||
it("should handle multiple intake schedules per medication", async () => {
|
||||
// Create medication with morning and evening doses
|
||||
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const morningStart = tomorrow.toISOString();
|
||||
|
||||
const eveningStart = new Date(tomorrow);
|
||||
eveningStart.setHours(20, 0, 0, 0);
|
||||
const eveningStartStr = eveningStart.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const morningStart = futureDailyStart;
|
||||
const eveningStartStr = futureEveningStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1103,8 +1079,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: morningStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1116,14 +1092,7 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate correct blisters needed", async () => {
|
||||
// 10 pills per blister, need 25 pills → need 3 blisters
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1142,8 +1111,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { fetchMock, requireAuthMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
requireAuthMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function createEmaRow(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
category: "Human",
|
||||
medicine_status: "Authorised",
|
||||
name_of_medicine: "Aspirin 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetylsalicylic acid",
|
||||
active_substance: "Acetylsalicylic acid",
|
||||
marketing_authorisation_developer_applicant_holder: "Bayer",
|
||||
therapeutic_area_mesh: "Pain",
|
||||
therapeutic_indication: "Pain relief",
|
||||
atc_code_human: "N02BA01",
|
||||
generic_or_hybrid: "No",
|
||||
biosimilar: "No",
|
||||
marketing_authorisation_date: "01/02/2024",
|
||||
ema_product_number: "EMA-ASPIRIN",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const { medicationEnrichmentRoutes } = await import("../routes/medication-enrichment.js");
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(sensible);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("medication enrichment", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
requireAuthMock.mockReset();
|
||||
requireAuthMock.mockImplementation(async () => {});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
it("normalizes German ingredient queries for EMA-backed search results", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
createEmaRow({
|
||||
name_of_medicine: "Ibuprofen 400 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Ibuprofen",
|
||||
active_substance: "Ibuprofen",
|
||||
ema_product_number: "EMA-IBUPROFEN",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Paracetamol 500 mg", 5);
|
||||
|
||||
expect(response.normalizedQuery).toBe("paracetamol 500 mg");
|
||||
expect(response.results).toHaveLength(1);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "EMA-TYLENOL",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
matchType: "ingredient",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires auth and returns EMA search results from the route", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(jsonResponse({ drugGroup: { conceptGroup: [] } }));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=aspirin&limit=1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requireAuthMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.json()).toMatchObject({
|
||||
query: "aspirin",
|
||||
normalizedQuery: "aspirin",
|
||||
hasMore: false,
|
||||
results: [
|
||||
{
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
source: "ema",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("falls back from EMA to RxNorm and openFDA search results when EMA has no match", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=semaglutide")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "12345",
|
||||
name: "Semaglutide 0.25 MG Oral Tablet [Wegovy]",
|
||||
synonym: "Wegovy 0.25 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Semaglutide", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "12345",
|
||||
name: "Wegovy",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "00011-1111",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "openfda",
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes RxNorm first, then openFDA, and keeps EMA last", async () => {
|
||||
const { searchMedicationEnrichment } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(jsonResponse([createEmaRow()]));
|
||||
}
|
||||
if (url.includes("/drugs.json?name=")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
drugGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
tty: "SBD",
|
||||
conceptProperties: [
|
||||
{
|
||||
rxcui: "1191",
|
||||
name: "Aspirin 500 MG Oral Tablet [Aspirin]",
|
||||
synonym: "Aspirin 500 mg oral tablet",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Bayer Aspirin",
|
||||
generic_name: "Acetylsalicylic acid",
|
||||
dosage_form: "Tablet",
|
||||
marketing_start_date: "20240101",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await searchMedicationEnrichment("Aspirin", 3);
|
||||
|
||||
expect(response.hasMore).toBe(false);
|
||||
expect(response.results).toHaveLength(3);
|
||||
expect(response.results[0]).toMatchObject({
|
||||
code: "1191",
|
||||
source: "rxnorm",
|
||||
});
|
||||
expect(response.results[1]).toMatchObject({
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
expect(response.results[2]).toMatchObject({
|
||||
code: "EMA-ASPIRIN",
|
||||
source: "ema",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates malformed search requests", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medication-enrichment/search?q=",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns enrichment suggestions with optional RxNorm strength data", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse({ idGroup: { rxnormId: ["161"] } }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Acetaminophen 500 MG Oral Tablet" },
|
||||
{ name: "Acetaminophen 650 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
selection: {
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
source: "ema+rxnorm",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" },
|
||||
{ label: "650 mg", pillWeightMg: 650, doseUnit: "mg" },
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: false,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps incomplete-coverage messaging honest when RxNorm enrichment fails", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("medicines-output-medicines_json-report_en.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse([
|
||||
createEmaRow({
|
||||
name_of_medicine: "Tylenol 500 mg tablets",
|
||||
international_non_proprietary_name_common_name: "Acetaminophen",
|
||||
active_substance: "Acetaminophen",
|
||||
ema_product_number: "EMA-TYLENOL",
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=acetaminophen&search=2")) {
|
||||
return Promise.reject(new Error("rxnorm timeout"));
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(jsonResponse({ results: [] }));
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Paracetamol",
|
||||
name: "Tylenol 500 mg tablets",
|
||||
genericName: "Acetaminophen",
|
||||
});
|
||||
|
||||
expect(response.selection.source).toBe("ema");
|
||||
expect(response.suggestions.strengthOptions).toEqual([]);
|
||||
expect(response.meta).toEqual({
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: false,
|
||||
partial: true,
|
||||
note: "Returned EMA enrichment without RxNorm suggestions.",
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches RxNorm selections by code and falls back to openFDA without best-match guessing", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("/rxcui/12345/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("api.fda.gov/drug/ndc.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "Ozempic",
|
||||
generic_name: "Semaglutide",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Semaglutide", strength: "2 mg" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "Ozempic",
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
code: "12345",
|
||||
source: "rxnorm",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "Ozempic",
|
||||
genericName: "Semaglutide",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "2 mg", pillWeightMg: 2, doseUnit: "mg" }],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: false,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches openFDA selections by code and augments them with RxNorm strength data", async () => {
|
||||
const { enrichMedicationSelection } = await import("../services/medication-enrichment.js");
|
||||
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.includes("search=product_ndc%3A%2200011-1111%22")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
results: [
|
||||
{
|
||||
product_ndc: "00011-1111",
|
||||
brand_name: "US Ibuprofen",
|
||||
generic_name: "Ibuprofen",
|
||||
dosage_form: "Tablet",
|
||||
active_ingredients: [{ name: "Ibuprofen", strength: "200 mg" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (url.includes("/rxcui.json?name=ibuprofen&search=2")) {
|
||||
return Promise.resolve(jsonResponse({ idGroup: { rxnormId: ["161"] } }));
|
||||
}
|
||||
if (url.includes("/rxcui/161/related.json")) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
relatedGroup: {
|
||||
conceptGroup: [
|
||||
{
|
||||
conceptProperties: [
|
||||
{ name: "Ibuprofen 200 MG Oral Tablet" },
|
||||
{ name: "Ibuprofen 400 MG Oral Tablet" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||
});
|
||||
|
||||
const response = await enrichMedicationSelection({
|
||||
query: "US Ibuprofen",
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
code: "00011-1111",
|
||||
source: "openfda",
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({
|
||||
selection: {
|
||||
name: "US Ibuprofen",
|
||||
genericName: "Ibuprofen",
|
||||
source: "rxnorm+openfda",
|
||||
},
|
||||
suggestions: {
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [
|
||||
{ label: "200 mg", pillWeightMg: 200, doseUnit: "mg" },
|
||||
{ label: "400 mg", pillWeightMg: 400, doseUnit: "mg" },
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: true,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not found when an explicit selection cannot be resolved", async () => {
|
||||
const app = await buildApp();
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([createEmaRow()]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medication-enrichment/enrich",
|
||||
payload: {
|
||||
query: "Unknown",
|
||||
name: "Completely Different Medication",
|
||||
genericName: "No match",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toMatchObject({
|
||||
code: "MEDICATION_ENRICHMENT_NOT_FOUND",
|
||||
error: "Selected medication could not be resolved.",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -6,22 +6,30 @@ import {
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
cleanOldIntakeReminders,
|
||||
countScheduledOccurrencesInRange,
|
||||
createDefaultIntakeReminderState,
|
||||
createDefaultReminderState,
|
||||
forEachScheduledOccurrenceInRange,
|
||||
formatInTimezone,
|
||||
getAverageOccurrencesPerDay,
|
||||
getCurrentHourInTimezone,
|
||||
getMaxScheduledGapDays,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type Intake,
|
||||
normalizeIntake,
|
||||
parseBlisters,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
personTakesMedication,
|
||||
type Weekday,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Helper to convert Blister to Intake for tests
|
||||
@@ -267,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Intake Schedule Normalization", () => {
|
||||
describe("normalizeIntake", () => {
|
||||
it("keeps interval schedules backward-compatible by default", () => {
|
||||
const intake = normalizeIntake({
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-01T08:00:00",
|
||||
});
|
||||
|
||||
expect(intake).toMatchObject({
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-01T08:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes malformed weekday schedules to the start date weekday", () => {
|
||||
const intake = normalizeIntake({
|
||||
usage: 1,
|
||||
every: 99,
|
||||
start: "2025-01-06T08:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["bogus", null],
|
||||
});
|
||||
|
||||
expect(intake.scheduleMode).toBe("weekdays");
|
||||
expect(intake.every).toBe(1);
|
||||
expect(intake.weekdays).toEqual(["mon"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIntakesJson", () => {
|
||||
it("falls back to legacy interval data when unified intakes are absent", () => {
|
||||
const intakes = parseIntakesJson(
|
||||
null,
|
||||
{
|
||||
usageJson: "[1,2]",
|
||||
everyJson: "[1,3]",
|
||||
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
expect(intakes).toEqual([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2025-01-01T08:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
{
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-02T20:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Daily Usage Calculation", () => {
|
||||
describe("calculateDailyUsage", () => {
|
||||
it("should calculate daily usage for single daily dose", () => {
|
||||
@@ -306,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
|
||||
it("calculates average usage and gap length for weekday schedules", () => {
|
||||
const weekdaysSchedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
|
||||
};
|
||||
|
||||
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
|
||||
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
|
||||
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
|
||||
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
|
||||
});
|
||||
|
||||
it("finds the next weekday occurrence after a given timestamp", () => {
|
||||
const schedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
|
||||
};
|
||||
|
||||
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
|
||||
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
|
||||
|
||||
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
|
||||
});
|
||||
|
||||
it("iterates weekday occurrences in canonical order within a range", () => {
|
||||
const schedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
|
||||
};
|
||||
const occurrences: number[] = [];
|
||||
|
||||
forEachScheduledOccurrenceInRange(
|
||||
schedule,
|
||||
new Date(2025, 0, 6, 0, 0, 0).getTime(),
|
||||
new Date(2025, 0, 12, 23, 59, 59).getTime(),
|
||||
(occurrenceMs) => {
|
||||
occurrences.push(occurrenceMs);
|
||||
}
|
||||
);
|
||||
|
||||
expect(occurrences.sort((a, b) => a - b)).toEqual([
|
||||
new Date(2025, 0, 6, 9, 0, 0).getTime(),
|
||||
new Date(2025, 0, 8, 9, 0, 0).getTime(),
|
||||
new Date(2025, 0, 10, 9, 0, 0).getTime(),
|
||||
]);
|
||||
expect(
|
||||
countScheduledOccurrencesInRange(
|
||||
schedule,
|
||||
new Date(2025, 0, 6, 0, 0, 0).getTime(),
|
||||
new Date(2025, 0, 12, 23, 59, 59).getTime()
|
||||
)
|
||||
).toEqual({
|
||||
count: 3,
|
||||
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Depletion Calculation", () => {
|
||||
describe("calculateDepletionInfo", () => {
|
||||
it("should calculate days left correctly", () => {
|
||||
@@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
expect(result[0].pillWeightMg).toBe(500);
|
||||
});
|
||||
|
||||
it("should skip blisters with zero interval", () => {
|
||||
it("should treat zero interval as a daily fallback", () => {
|
||||
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
|
||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
medName: "TestMed",
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple blisters", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
createTestMedication,
|
||||
createTestShareToken,
|
||||
createTestUser,
|
||||
setUserSettings,
|
||||
type TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
|
||||
@@ -6,14 +6,34 @@
|
||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
|
||||
|
||||
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
|
||||
export type IntakeScheduleMode = "interval" | "weekdays";
|
||||
|
||||
type ScheduleLike = {
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
};
|
||||
|
||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||
export type Blister = { usage: number; every: number; start: string };
|
||||
export type Blister = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
};
|
||||
|
||||
// New unified intake type with per-intake takenBy
|
||||
export type Intake = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||
intakeRemindersEnabled: boolean;
|
||||
@@ -22,6 +42,278 @@ export type Intake = {
|
||||
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
|
||||
value === "ml" || value === "tsp" || value === "tbsp";
|
||||
|
||||
const weekdayToJavascriptDay: Record<Weekday, number> = {
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
sun: 0,
|
||||
};
|
||||
|
||||
function isWeekday(value: unknown): value is Weekday {
|
||||
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
|
||||
}
|
||||
|
||||
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
|
||||
return value === "weekdays" ? "weekdays" : "interval";
|
||||
}
|
||||
|
||||
function toDateOnly(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function getDateOnlyTimestamp(date: Date): number {
|
||||
return toDateOnly(date).getTime();
|
||||
}
|
||||
|
||||
export function getWeekdayFromDate(date: Date): Weekday {
|
||||
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
|
||||
return weekday ?? "mon";
|
||||
}
|
||||
|
||||
export function getWeekdayFromStart(start: string): Weekday {
|
||||
const startDate = parseLocalDateTime(start);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
return "mon";
|
||||
}
|
||||
return getWeekdayFromDate(startDate);
|
||||
}
|
||||
|
||||
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [getWeekdayFromStart(start)];
|
||||
}
|
||||
|
||||
const uniqueWeekdays = new Set<Weekday>();
|
||||
for (const weekday of value) {
|
||||
if (isWeekday(weekday)) {
|
||||
uniqueWeekdays.add(weekday);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
|
||||
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
|
||||
}
|
||||
|
||||
function createOccurrenceAtDate(date: Date, startDate: Date): number {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes(),
|
||||
startDate.getSeconds(),
|
||||
startDate.getMilliseconds()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (schedule.weekdays && schedule.weekdays.length > 0) {
|
||||
return schedule.weekdays;
|
||||
}
|
||||
|
||||
return [getWeekdayFromStart(schedule.start)];
|
||||
}
|
||||
|
||||
export function getAverageOccurrencesPerDay(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
if (schedule.scheduleMode === "weekdays") {
|
||||
return getNormalizedWeekdays(schedule).length / 7;
|
||||
}
|
||||
|
||||
return 1 / Math.max(1, schedule.every);
|
||||
}
|
||||
|
||||
export function getMaxScheduledGapDays(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
return Math.max(1, schedule.every);
|
||||
}
|
||||
|
||||
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
|
||||
if (weekdays.length === 0) {
|
||||
return 7;
|
||||
}
|
||||
|
||||
let maxGap = 0;
|
||||
for (let index = 0; index < weekdays.length; index++) {
|
||||
const current = weekdays[index];
|
||||
const next = weekdays[(index + 1) % weekdays.length];
|
||||
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
}
|
||||
}
|
||||
|
||||
return maxGap || 7;
|
||||
}
|
||||
|
||||
export function getScheduleMatchWindowMs(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
|
||||
}
|
||||
|
||||
export function getNextScheduledOccurrenceTime(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
fromMs: number,
|
||||
inclusive: boolean = true
|
||||
): number | null {
|
||||
const startDate = parseLocalDateTime(schedule.start);
|
||||
const startTime = startDate.getTime();
|
||||
if (Number.isNaN(startTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
if (startTime >= lowerBound) {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
||||
return startTime + intervals * period;
|
||||
}
|
||||
|
||||
const candidateStart = Math.max(lowerBound, startTime);
|
||||
const candidateDateOnly = toDateOnly(new Date(candidateStart));
|
||||
let nextOccurrence: number | null = null;
|
||||
|
||||
for (const weekday of getNormalizedWeekdays(schedule)) {
|
||||
const candidateDate = new Date(candidateDateOnly);
|
||||
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
|
||||
candidateDate.setDate(candidateDate.getDate() + offsetDays);
|
||||
|
||||
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
|
||||
if (occurrenceMs < candidateStart) {
|
||||
candidateDate.setDate(candidateDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
|
||||
}
|
||||
|
||||
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
|
||||
nextOccurrence = occurrenceMs;
|
||||
}
|
||||
}
|
||||
|
||||
return nextOccurrence;
|
||||
}
|
||||
|
||||
export function forEachScheduledOccurrenceInRange(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
callback: (occurrenceMs: number) => void
|
||||
): void {
|
||||
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = parseLocalDateTime(schedule.start);
|
||||
const startTime = startDate.getTime();
|
||||
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
let occurrenceMs = startTime;
|
||||
if (occurrenceMs < rangeStartMs) {
|
||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
||||
occurrenceMs += intervals * period;
|
||||
}
|
||||
|
||||
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
|
||||
if (occurrenceMs >= rangeStartMs) {
|
||||
callback(occurrenceMs);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerBound = Math.max(rangeStartMs, startTime);
|
||||
const firstDateOnly = toDateOnly(new Date(lowerBound));
|
||||
|
||||
for (const weekday of getNormalizedWeekdays(schedule)) {
|
||||
const occurrenceDate = new Date(firstDateOnly);
|
||||
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
|
||||
|
||||
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
if (occurrenceMs < lowerBound) {
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
}
|
||||
|
||||
while (occurrenceMs <= rangeEndMs) {
|
||||
callback(occurrenceMs);
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function countScheduledOccurrencesInRange(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number
|
||||
): { count: number; lastOccurrenceMs: number | null } {
|
||||
let count = 0;
|
||||
let lastOccurrenceMs: number | null = null;
|
||||
|
||||
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
|
||||
count += 1;
|
||||
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
|
||||
lastOccurrenceMs = occurrenceMs;
|
||||
}
|
||||
});
|
||||
|
||||
return { count, lastOccurrenceMs };
|
||||
}
|
||||
|
||||
export function normalizeIntake(
|
||||
value: {
|
||||
usage?: unknown;
|
||||
every?: unknown;
|
||||
start?: unknown;
|
||||
scheduleMode?: unknown;
|
||||
weekdays?: unknown;
|
||||
intakeUnit?: unknown;
|
||||
takenBy?: unknown;
|
||||
intakeRemindersEnabled?: unknown;
|
||||
},
|
||||
defaultIntakeRemindersEnabled: boolean = false
|
||||
): Intake {
|
||||
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
|
||||
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
|
||||
let every = 1;
|
||||
if (scheduleMode !== "weekdays") {
|
||||
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
|
||||
every = value.every;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
|
||||
every,
|
||||
start,
|
||||
scheduleMode,
|
||||
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
|
||||
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
|
||||
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
|
||||
intakeRemindersEnabled:
|
||||
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize intake usage for stock math.
|
||||
*
|
||||
@@ -225,15 +517,7 @@ export function parseIntakesJson(
|
||||
try {
|
||||
const parsed = JSON.parse(intakesJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((intake: Record<string, unknown>) => ({
|
||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||
every: typeof intake.every === "number" ? intake.every : 1,
|
||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
|
||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||
intakeRemindersEnabled:
|
||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||
}));
|
||||
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy parsing
|
||||
@@ -243,14 +527,18 @@ export function parseIntakesJson(
|
||||
// Fallback to legacy parallel arrays
|
||||
if (legacyRow) {
|
||||
const blisters = parseBlisters(legacyRow);
|
||||
return blisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // Legacy format has no per-intake takenBy
|
||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||
}));
|
||||
return blisters.map((b) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
medicationIntakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
|
||||
|
||||
/** Calculate daily usage from blisters */
|
||||
export function calculateDailyUsage(blisters: Blister[]): number {
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
|
||||
}
|
||||
|
||||
/** Calculate depletion information for a medication */
|
||||
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
// If intake has its own takenBy, use it; otherwise null (no specific person)
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
result.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
|
||||
const intakeDate = new Date(occurrenceMs);
|
||||
result.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,40 +713,11 @@ export function getUpcomingIntakes(
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence notification time falls in current minute and intake hasn't happened
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
|
||||
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
|
||||
// but the intake time is still in the future — include it so the advance
|
||||
// reminder can still be sent rather than falling into a dead zone.
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
|
||||
if (nextTime === null) continue;
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
@@ -114,8 +114,10 @@ test.describe("Share Schedule", () => {
|
||||
const personSelect = modal.locator("select").first();
|
||||
await expect(personSelect).toBeVisible();
|
||||
|
||||
// Should contain Alice and Bob options
|
||||
await expect(personSelect.locator("option")).toHaveCount(2);
|
||||
// Should contain Alice and Bob options.
|
||||
// The dialog can also include an "all people" option, so assert presence instead of exact count.
|
||||
await expect(personSelect.locator('option[value="Alice"]')).toBeAttached();
|
||||
await expect(personSelect.locator('option[value="Bob"]')).toBeAttached();
|
||||
|
||||
// Close
|
||||
await page.locator("button.modal-close").click();
|
||||
@@ -187,7 +189,7 @@ test.describe("Share Schedule", () => {
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The page should show Alice's medication name
|
||||
const content = sharedSchedule.getByText(MED_ALICE);
|
||||
const content = sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first();
|
||||
try {
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
@@ -236,12 +238,16 @@ test.describe("Share Schedule", () => {
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
} catch {
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// Visit Bob's share — should show Bob's med
|
||||
@@ -251,12 +257,16 @@ test.describe("Share Schedule", () => {
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
} catch {
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Generated
+784
-1572
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.20.1",
|
||||
"version": "1.21.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -46,11 +46,11 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.1.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getPackageSize,
|
||||
type IntakeUnit,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
@@ -254,32 +257,16 @@ export function MedDetailModal({
|
||||
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
|
||||
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
|
||||
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
|
||||
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||
const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => {
|
||||
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||
if (intakeUnit === "tsp") {
|
||||
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
||||
}
|
||||
if (intakeUnit === "tbsp") {
|
||||
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
|
||||
}
|
||||
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
|
||||
}
|
||||
if (isTubePackageType(selectedMed.packageType)) {
|
||||
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||
}
|
||||
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
};
|
||||
const scheduleIntakes =
|
||||
selectedMed.intakes && selectedMed.intakes.length > 0
|
||||
? selectedMed.intakes
|
||||
: selectedMed.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
intakeUnit: null,
|
||||
}));
|
||||
const scheduleIntakes = getMedicationIntakes(selectedMed);
|
||||
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
|
||||
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
||||
let normalizedFull = Math.max(0, nextFull);
|
||||
@@ -969,7 +956,7 @@ export function MedDetailModal({
|
||||
</div>
|
||||
|
||||
{/* Intake Schedule Section */}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
{scheduleIntakes.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{t("modal.intakeSchedule")}{" "}
|
||||
@@ -980,24 +967,20 @@ export function MedDetailModal({
|
||||
)}
|
||||
</h3>
|
||||
<div className="med-detail-schedules">
|
||||
{scheduleIntakes.map((intake, idx) => {
|
||||
{scheduleIntakes.map((intake) => {
|
||||
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||
const showIntakeBell = intake.intakeRemindersEnabled === true;
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
|
||||
className="med-schedule-row blister-row-simple"
|
||||
>
|
||||
<div key={intakeKey} className="med-schedule-row blister-row-simple">
|
||||
<span className="med-schedule-usage">
|
||||
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||
</span>
|
||||
<span className="med-schedule-freq">{getIntakeFrequencyText(intake, t)}</span>
|
||||
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
@@ -1168,7 +1151,7 @@ export function MedDetailModal({
|
||||
<FilePenLine size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
{scheduleIntakes.length > 0 && (
|
||||
<button
|
||||
className="secondary icon-only tooltip-trigger"
|
||||
onClick={() => generateICS(selectedMed)}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MedicationEnrichmentEnrichResponse,
|
||||
MedicationEnrichmentSearchResult,
|
||||
MedicationEnrichmentStrengthOption,
|
||||
} from "../types";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
|
||||
export interface MedicationEnrichmentViewModel {
|
||||
query: string;
|
||||
results: MedicationEnrichmentSearchResult[];
|
||||
hasMoreResults?: boolean;
|
||||
isSearching: boolean;
|
||||
hasSearched: boolean;
|
||||
searchError: string | null;
|
||||
applyingCode: string | null;
|
||||
activeResultCode: string | null;
|
||||
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
|
||||
enrichError: string | null;
|
||||
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
|
||||
strengthOptions: MedicationEnrichmentStrengthOption[];
|
||||
appliedStrengthLabel: string | null;
|
||||
}
|
||||
|
||||
export interface MedicationEnrichmentSectionProps {
|
||||
state: MedicationEnrichmentViewModel;
|
||||
onQueryChange: (value: string) => void;
|
||||
onSearch: () => void;
|
||||
onLoadMoreResults?: () => void;
|
||||
onApplyResult: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onApplyStrength: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
}
|
||||
|
||||
export function MedicationEnrichmentSection({
|
||||
state,
|
||||
onQueryChange,
|
||||
onSearch,
|
||||
onLoadMoreResults,
|
||||
onApplyResult,
|
||||
onApplyStrength,
|
||||
}: MedicationEnrichmentSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const canSearch = state.query.trim().length > 0 && !state.isSearching && !state.applyingCode;
|
||||
const shouldAutoExpand =
|
||||
state.isSearching ||
|
||||
state.hasSearched ||
|
||||
state.searchError !== null ||
|
||||
state.enrichError !== null ||
|
||||
state.results.length > 0 ||
|
||||
state.appliedSelection !== null ||
|
||||
state.strengthOptions.length > 0 ||
|
||||
state.appliedStrengthLabel !== null ||
|
||||
Boolean(state.meta?.partial);
|
||||
const [isExpanded, setIsExpanded] = useState(shouldAutoExpand);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [expandedResultCode, setExpandedResultCode] = useState<string | null>(null);
|
||||
const autoExpandStateRef = useRef(shouldAutoExpand);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoExpand && !autoExpandStateRef.current) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
|
||||
autoExpandStateRef.current = shouldAutoExpand;
|
||||
}, [shouldAutoExpand]);
|
||||
|
||||
return (
|
||||
<div className="full medication-enrichment-section">
|
||||
<div className="medication-enrichment-header">
|
||||
<div>
|
||||
<h5 className="form-category-title medication-enrichment-title">{t("form.enrichment.title")}</h5>
|
||||
<p className="sub medication-enrichment-collapsed-hint">{t("form.enrichment.collapsedHint")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
>
|
||||
{isExpanded ? t("form.enrichment.toggleHide") : t("form.enrichment.toggleShow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="medication-enrichment-body">
|
||||
<div className="medication-enrichment-helper-row">
|
||||
<span className="status-chip small warning">{t("form.enrichment.coverageLabel")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost small"
|
||||
aria-expanded={showInfo}
|
||||
onClick={() => setShowInfo((current) => !current)}
|
||||
>
|
||||
{showInfo ? t("form.enrichment.infoHide") : t("form.enrichment.infoShow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showInfo ? (
|
||||
<div className="medication-enrichment-info">
|
||||
<p className="medication-enrichment-info-title">{t("form.enrichment.infoTitle")}</p>
|
||||
<p className="sub medication-enrichment-description">{t("form.enrichment.description")}</p>
|
||||
<p className="sub medication-enrichment-manual-hint">{t("form.enrichment.manualEntryHint")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="full">
|
||||
{t("form.enrichment.searchLabel")}
|
||||
<div className="medication-enrichment-search-row">
|
||||
<input
|
||||
type="search"
|
||||
value={state.query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
if (!canSearch) return;
|
||||
onSearch();
|
||||
}}
|
||||
placeholder={t("form.enrichment.searchPlaceholder")}
|
||||
/>
|
||||
<button type="button" className="secondary small" onClick={onSearch} disabled={!canSearch}>
|
||||
{state.isSearching ? t("form.enrichment.searching") : t("form.enrichment.searchAction")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{state.searchError ? <p className="danger-text">{state.searchError}</p> : null}
|
||||
{state.enrichError ? <p className="danger-text">{state.enrichError}</p> : null}
|
||||
{state.meta?.partial ? <p className="info-text">{t("form.enrichment.partialNote")}</p> : null}
|
||||
{state.hasSearched && !state.isSearching && state.results.length === 0 ? (
|
||||
<p className="info-text">{t("form.enrichment.noResults")}</p>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 ? (
|
||||
<div className="medication-enrichment-results">
|
||||
{state.results.map((result) => {
|
||||
const isActive = state.activeResultCode === result.code;
|
||||
const hasDetails = Boolean(
|
||||
result.authorisationHolder || result.therapeuticArea || result.authorisationDate
|
||||
);
|
||||
const isDetailsExpanded = expandedResultCode === result.code;
|
||||
const genericStatusClass = result.genericStatus === "generic" ? "success" : "neutral";
|
||||
const sourceClass = result.source === "openfda" ? "warning" : "neutral";
|
||||
let applyLabel = t("form.enrichment.applyAction");
|
||||
if (state.applyingCode === result.code) {
|
||||
applyLabel = t("form.enrichment.applying");
|
||||
} else if (isActive && state.appliedSelection) {
|
||||
applyLabel = t("form.enrichment.applied");
|
||||
}
|
||||
|
||||
return (
|
||||
<article key={result.code} className={`medication-enrichment-result${isActive ? " active" : ""}`}>
|
||||
<div className="medication-enrichment-result-header">
|
||||
<div className="medication-enrichment-result-names">
|
||||
<strong>{result.name}</strong>
|
||||
{result.genericName ? (
|
||||
<span className="medication-enrichment-result-generic">{result.genericName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="medication-enrichment-result-actions">
|
||||
<span className={`pill ${sourceClass}`}>{t(`form.enrichment.sources.${result.source}`)}</span>
|
||||
{result.source === "ema" ? (
|
||||
<span className={`pill ${genericStatusClass}`}>
|
||||
{t(`form.enrichment.genericStatus.${result.genericStatus}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost small"
|
||||
aria-expanded={isDetailsExpanded}
|
||||
onClick={() =>
|
||||
setExpandedResultCode((current) => (current === result.code ? null : result.code))
|
||||
}
|
||||
>
|
||||
{isDetailsExpanded
|
||||
? t("form.enrichment.details.hideAction")
|
||||
: t("form.enrichment.details.showAction")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={isActive ? "secondary small" : "primary small"}
|
||||
onClick={() => {
|
||||
setExpandedResultCode(result.code);
|
||||
onApplyResult(result);
|
||||
}}
|
||||
disabled={state.applyingCode === result.code}
|
||||
>
|
||||
{applyLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDetails && isDetailsExpanded ? (
|
||||
<dl className="medication-enrichment-result-meta">
|
||||
{result.authorisationHolder ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationHolder")}</dt>
|
||||
<dd>{result.authorisationHolder}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.therapeuticArea ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.therapeuticArea")}</dt>
|
||||
<dd>{result.therapeuticArea}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{result.authorisationDate ? (
|
||||
<div>
|
||||
<dt>{t("form.enrichment.details.authorisationDate")}</dt>
|
||||
<dd>{formatDate(result.authorisationDate)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.results.length > 0 && state.hasMoreResults && onLoadMoreResults ? (
|
||||
<div className="medication-enrichment-results-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary small"
|
||||
onClick={onLoadMoreResults}
|
||||
disabled={state.isSearching || Boolean(state.applyingCode)}
|
||||
>
|
||||
{t("form.enrichment.showMoreAction")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.appliedSelection || state.strengthOptions.length > 0 || state.appliedStrengthLabel ? (
|
||||
<div className="medication-enrichment-followup">
|
||||
{state.appliedSelection ? (
|
||||
<div>
|
||||
<p className="success-text">{t("form.enrichment.applied")}</p>
|
||||
<p className="sub medication-enrichment-selection-summary">
|
||||
<strong>{state.appliedSelection.name}</strong>
|
||||
{state.appliedSelection.genericName ? ` • ${state.appliedSelection.genericName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.strengthOptions.length > 0 ? (
|
||||
<div className="medication-enrichment-strengths">
|
||||
<p className="medication-enrichment-strength-title">{t("form.enrichment.strengthTitle")}</p>
|
||||
<p className="sub">{t("form.enrichment.strengthHint")}</p>
|
||||
<div className="medication-enrichment-strength-list">
|
||||
{state.strengthOptions.map((option) => {
|
||||
const isSelected = state.appliedStrengthLabel === option.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
className={isSelected ? "primary small" : "secondary small"}
|
||||
onClick={() => onApplyStrength(option)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.appliedStrengthLabel ? (
|
||||
<p className="success-text">
|
||||
{t("form.enrichment.appliedStrength", { label: state.appliedStrengthLabel })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
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 {
|
||||
allowsPillFormSelection,
|
||||
DOSE_UNITS,
|
||||
@@ -19,8 +28,17 @@ import {
|
||||
PACKAGE_PROFILES,
|
||||
} from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
import {
|
||||
getIntakeScheduleMode,
|
||||
getWeekdayLabel,
|
||||
hasSelectedWeekdays,
|
||||
toggleWeekdaySelection,
|
||||
WEEKDAY_CODES,
|
||||
} from "../utils/intake-schedule";
|
||||
import { DateInput } from "./DateInput";
|
||||
import { FormNumberStepper } from "./FormNumberStepper";
|
||||
import type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||
import { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -33,11 +51,33 @@ const FIELD_LIMITS = {
|
||||
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
||||
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 {
|
||||
show: boolean;
|
||||
editingId: number | null;
|
||||
form: FormState;
|
||||
onFormChange: (form: FormState) => void;
|
||||
medicationEnrichment?: MedicationEnrichmentViewModel;
|
||||
onMedicationEnrichmentQueryChange?: (value: string) => void;
|
||||
onMedicationEnrichmentSearch?: () => void;
|
||||
onMedicationEnrichmentLoadMore?: () => void;
|
||||
onMedicationEnrichmentApply?: (result: MedicationEnrichmentSearchResult) => void;
|
||||
onMedicationEnrichmentStrengthApply?: (option: MedicationEnrichmentStrengthOption) => void;
|
||||
fieldErrors: FieldErrors;
|
||||
saving: boolean;
|
||||
formSaved: boolean;
|
||||
@@ -57,7 +97,7 @@ export interface MobileEditModalProps {
|
||||
onAddBlister: () => void;
|
||||
onRemoveBlister: (idx: number) => void;
|
||||
// Intake helpers (new - with per-intake takenBy)
|
||||
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
|
||||
onSetIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
|
||||
onAddIntake: (takenBy?: string) => void;
|
||||
onRemoveIntake: (idx: number) => void;
|
||||
// Value change handler for numeric fields
|
||||
@@ -90,6 +130,12 @@ export function MobileEditModal({
|
||||
editingId,
|
||||
form,
|
||||
onFormChange,
|
||||
medicationEnrichment = EMPTY_MEDICATION_ENRICHMENT,
|
||||
onMedicationEnrichmentQueryChange = () => {},
|
||||
onMedicationEnrichmentSearch = () => {},
|
||||
onMedicationEnrichmentLoadMore = () => {},
|
||||
onMedicationEnrichmentApply = () => {},
|
||||
onMedicationEnrichmentStrengthApply = () => {},
|
||||
fieldErrors,
|
||||
saving,
|
||||
formSaved,
|
||||
@@ -158,6 +204,24 @@ export function MobileEditModal({
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
const weekdayOptions = useMemo(
|
||||
() =>
|
||||
WEEKDAY_CODES.map((day) => ({
|
||||
value: day,
|
||||
shortLabel: getWeekdayLabel(day, t, "short"),
|
||||
longLabel: getWeekdayLabel(day, t, "long"),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const hasWeekdaySelectionError = useCallback(
|
||||
(intake: (typeof form.intakes)[number]) =>
|
||||
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
|
||||
[]
|
||||
);
|
||||
const hasWeekdayScheduleError = useMemo(
|
||||
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
|
||||
[form.intakes, hasWeekdaySelectionError]
|
||||
);
|
||||
|
||||
// Reset tab when modal opens
|
||||
useEffect(() => {
|
||||
@@ -421,6 +485,14 @@ export function MobileEditModal({
|
||||
<span className="field-error">{fieldErrors.genericName}</span>
|
||||
)}
|
||||
</label>
|
||||
<MedicationEnrichmentSection
|
||||
state={medicationEnrichment}
|
||||
onQueryChange={onMedicationEnrichmentQueryChange}
|
||||
onSearch={onMedicationEnrichmentSearch}
|
||||
onLoadMoreResults={onMedicationEnrichmentLoadMore}
|
||||
onApplyResult={onMedicationEnrichmentApply}
|
||||
onApplyStrength={onMedicationEnrichmentStrengthApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
{t("form.medicationStartDate")}
|
||||
@@ -814,106 +886,153 @@ export function MobileEditModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div
|
||||
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}-${idx}`}
|
||||
className="blister-row"
|
||||
>
|
||||
<label className="compact">
|
||||
<span>{getUsageLabel(intake)}</span>
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.intakeUnit")}</span>
|
||||
{form.intakes.map((intake, idx) => {
|
||||
const scheduleMode = getIntakeScheduleMode(intake);
|
||||
const selectedWeekdays = intake.weekdays ?? [];
|
||||
const intakeKey = `${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${scheduleMode}-${selectedWeekdays.join("")}-${intake.takenBy ?? ""}-${intake.intakeUnit ?? "unit"}-${intake.intakeRemindersEnabled ? "reminder" : "silent"}`;
|
||||
return (
|
||||
<div key={intakeKey} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{getUsageLabel(intake)}</span>
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.scheduleMode")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
value={scheduleMode}
|
||||
onChange={(e) =>
|
||||
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
onSetIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
|
||||
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row taken-by-field">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => onSetIntakeValue(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="legend-hint">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
{scheduleMode === "interval" ? (
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.weekdays")}</span>
|
||||
<div className="badges">
|
||||
{weekdayOptions.map((weekday) => {
|
||||
const isSelected = selectedWeekdays.includes(weekday.value);
|
||||
return (
|
||||
<button
|
||||
key={weekday.value}
|
||||
type="button"
|
||||
className={isSelected ? "pill clickable" : "pill clickable neutral"}
|
||||
aria-pressed={isSelected}
|
||||
title={weekday.longLabel}
|
||||
onClick={() =>
|
||||
onSetIntakeValue(
|
||||
idx,
|
||||
"weekdays",
|
||||
toggleWeekdaySelection(selectedWeekdays, weekday.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{weekday.shortLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!readOnlyMode && hasWeekdaySelectionError(intake) && (
|
||||
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.intakeUnit")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
onSetIntakeValue(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="compact full-row taken-by-field">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => onSetIntakeValue(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="legend-hint">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{!readOnlyMode && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger remove-blister-btn icon-only tooltip-trigger"
|
||||
onClick={() => onRemoveIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!readOnlyMode && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger remove-blister-btn icon-only tooltip-trigger"
|
||||
onClick={() => onRemoveIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
|
||||
@@ -984,7 +1103,9 @@ export function MobileEditModal({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || (!formChanged && (formSaved || !!editingId))}
|
||||
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
|
||||
className={
|
||||
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
|
||||
}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatDate, formatDateTime } from "../utils/formatters";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
|
||||
type ReportFormat = "txt" | "md" | "pdf";
|
||||
@@ -290,20 +292,6 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
|
||||
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "-";
|
||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return "-";
|
||||
return `${m[3]}.${m[2]}.${m[1]}`;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "-";
|
||||
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||
if (!m) return `${fmtDate(iso)}`;
|
||||
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
||||
}
|
||||
|
||||
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
||||
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
|
||||
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
||||
@@ -353,7 +341,7 @@ function generateTextReport(
|
||||
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
|
||||
|
||||
lines.push(h1(t("report.docTitle")));
|
||||
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
|
||||
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
|
||||
lines.push("");
|
||||
|
||||
for (const med of meds) {
|
||||
@@ -373,8 +361,8 @@ function generateTextReport(
|
||||
lines.push(
|
||||
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
|
||||
);
|
||||
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
|
||||
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
|
||||
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
|
||||
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
|
||||
lines.push("");
|
||||
|
||||
// Package / Stock
|
||||
@@ -391,24 +379,23 @@ function generateTextReport(
|
||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), formatDate(med.expiryDate)));
|
||||
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||
lines.push("");
|
||||
|
||||
// Intake Schedule
|
||||
const allIntakes = med.intakes ?? med.blisters;
|
||||
const allIntakes = getMedicationIntakes(med);
|
||||
const intakes = personFilter
|
||||
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||
? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
|
||||
: allIntakes;
|
||||
if (intakes?.length) {
|
||||
lines.push(h3(t("report.docIntakeSchedule")));
|
||||
for (const intake of intakes) {
|
||||
let entry = getUsageText(med, intake.usage, t);
|
||||
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
||||
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
||||
if ("takenBy" in intake && intake.takenBy)
|
||||
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
|
||||
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||
entry += ` ${getIntakeFrequencyText(intake, t)}`;
|
||||
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
|
||||
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
|
||||
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
lines.push("");
|
||||
@@ -420,7 +407,7 @@ function generateTextReport(
|
||||
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
|
||||
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
|
||||
if (med.prescriptionExpiryDate)
|
||||
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
|
||||
lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
@@ -434,8 +421,8 @@ function generateTextReport(
|
||||
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
||||
}
|
||||
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
|
||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
|
||||
} else {
|
||||
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
|
||||
}
|
||||
@@ -445,7 +432,7 @@ function generateTextReport(
|
||||
if (data.refills.length > 0) {
|
||||
lines.push(h3(t("report.docRefillHistory")));
|
||||
for (const r of data.refills) {
|
||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
@@ -528,7 +515,7 @@ function buildPrintHtml(
|
||||
|
||||
for (const med of meds) {
|
||||
const data = reportData[med.id];
|
||||
const intakes = med.intakes ?? med.blisters;
|
||||
const intakes = getMedicationIntakes(med);
|
||||
const displayName = getMedDisplayName(med);
|
||||
const title = med.isObsolete
|
||||
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
|
||||
@@ -560,11 +547,11 @@ function buildPrintHtml(
|
||||
);
|
||||
if (med.medicationStartDate)
|
||||
generalRows.push(
|
||||
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
|
||||
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
|
||||
);
|
||||
if (med.isObsolete && med.obsoleteAt)
|
||||
generalRows.push(
|
||||
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
|
||||
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
|
||||
);
|
||||
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
|
||||
|
||||
@@ -591,7 +578,7 @@ function buildPrintHtml(
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||
if (med.expiryDate)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
|
||||
if (med.notes)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
|
||||
s += `</tbody></table>`;
|
||||
@@ -599,18 +586,17 @@ function buildPrintHtml(
|
||||
// Intake Schedule
|
||||
const allPrintIntakes = intakes;
|
||||
const filteredPrintIntakes = personFilter
|
||||
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||
? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
|
||||
: allPrintIntakes;
|
||||
if (filteredPrintIntakes?.length) {
|
||||
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const intake of filteredPrintIntakes) {
|
||||
let entry = escHtml(getUsageText(med, intake.usage, t));
|
||||
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
||||
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
||||
if ("takenBy" in intake && intake.takenBy)
|
||||
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
|
||||
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
|
||||
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
|
||||
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
|
||||
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
s += `</ul>`;
|
||||
@@ -623,7 +609,7 @@ function buildPrintHtml(
|
||||
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
|
||||
if (med.prescriptionExpiryDate)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
|
||||
s += `</tbody></table>`;
|
||||
}
|
||||
|
||||
@@ -639,9 +625,9 @@ function buildPrintHtml(
|
||||
if (data.dosesDismissed > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||
if (data.firstDoseAt)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
|
||||
if (data.lastDoseAt)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
|
||||
s += `</tbody></table>`;
|
||||
} else {
|
||||
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
|
||||
@@ -652,7 +638,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const r of data.refills) {
|
||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
@@ -708,7 +694,7 @@ function buildPrintHtml(
|
||||
<body>
|
||||
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
||||
<h1>${escHtml(t("report.docTitle"))}</h1>
|
||||
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
|
||||
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
|
||||
${sections.join("\n")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SharedMedicationOverviewItem } from "../types";
|
||||
import {
|
||||
getPackageSize,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
type SharedMedicationOverviewItem,
|
||||
} from "../types";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
|
||||
function formatPackageInfo(medication: SharedMedicationOverviewItem): string {
|
||||
function formatPackageAmountUnit(medication: SharedMedicationOverviewItem, t: (key: string) => string): string | null {
|
||||
if (isTubePackageType(medication.packageType)) {
|
||||
return t("form.packageAmountUnitG");
|
||||
}
|
||||
|
||||
if (isLiquidContainerPackageType(medication.packageType)) {
|
||||
return t("form.packageAmountUnitMl");
|
||||
}
|
||||
|
||||
if (medication.packageAmountUnit === "g") {
|
||||
return t("form.packageAmountUnitG");
|
||||
}
|
||||
|
||||
if (medication.packageAmountUnit === "ml") {
|
||||
return t("form.packageAmountUnitMl");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPackageInfo(medication: SharedMedicationOverviewItem, t: (key: string) => string): string {
|
||||
if (medication.packageType === "blister") {
|
||||
return `${medication.packCount} x ${medication.blistersPerPack} x ${medication.pillsPerBlister}`;
|
||||
}
|
||||
|
||||
if (medication.totalPills !== null) {
|
||||
return `${medication.packCount} x ${medication.totalPills}`;
|
||||
const unitLabel = formatPackageAmountUnit(medication, t);
|
||||
if (unitLabel && medication.packageAmountValue && medication.packageAmountValue > 0) {
|
||||
const sizeLabel = `${medication.packageAmountValue} ${unitLabel}`;
|
||||
return medication.packCount > 1 ? `${medication.packCount} x ${sizeLabel}` : sizeLabel;
|
||||
}
|
||||
|
||||
return `${medication.packCount}`;
|
||||
const packageSize = getPackageSize(medication);
|
||||
if (packageSize > 0) {
|
||||
return medication.packCount > 1 ? `${medication.packCount} x ${packageSize}` : `${packageSize}`;
|
||||
}
|
||||
|
||||
return `${Math.max(medication.packCount, 1)}`;
|
||||
}
|
||||
|
||||
function getOverviewStatus(
|
||||
@@ -105,7 +137,7 @@ export function SharedMedicationOverviewSection({
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPackageInfo(medication)}</td>
|
||||
<td>{formatPackageInfo(medication, t)}</td>
|
||||
<td>
|
||||
<span className="shared-overview-stock-value">
|
||||
{medication.currentStock === null || medication.capacity === null
|
||||
@@ -158,7 +190,7 @@ export function SharedMedicationOverviewSection({
|
||||
</div>
|
||||
<div className="shared-overview-card-grid">
|
||||
<span>{t("sharedOverview.columns.package")}</span>
|
||||
<strong>{formatPackageInfo(medication)}</strong>
|
||||
<strong>{formatPackageInfo(medication, t)}</strong>
|
||||
<span>{t("sharedOverview.columns.stock")}</span>
|
||||
<strong>
|
||||
<span className="shared-overview-stock-value">
|
||||
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
allowsPillFormSelection,
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
type IntakeUnit,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
type StockThresholds,
|
||||
} from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
|
||||
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
|
||||
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
|
||||
@@ -39,16 +43,10 @@ export function SharedSchedule() {
|
||||
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
||||
isLiquidContainerPackageType(med?.packageType);
|
||||
|
||||
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||
if (unit === "tsp") return usage * 5;
|
||||
if (unit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
};
|
||||
|
||||
const convertUsageForStock = (
|
||||
usage: number,
|
||||
med: SharedScheduleData["medications"][number] | undefined,
|
||||
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
||||
unit: IntakeUnit | null | undefined
|
||||
): number => {
|
||||
if (isTubePackageType(med?.packageType)) return 0;
|
||||
if (!isLiquidContainerMed(med)) return usage;
|
||||
@@ -60,13 +58,7 @@ export function SharedSchedule() {
|
||||
return String(rounded);
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
|
||||
const normalizedUsage = Number(usage);
|
||||
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||
@@ -77,13 +69,13 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
med: SharedScheduleData["medications"][number] | undefined,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
intakeUnit?: IntakeUnit | null
|
||||
) => {
|
||||
if (isLiquidContainerMed(med)) {
|
||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||
@@ -94,7 +86,7 @@ export function SharedSchedule() {
|
||||
const formatTotalUsageLabel = (
|
||||
med: SharedScheduleData["medications"][number] | undefined,
|
||||
total: number,
|
||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => {
|
||||
if (isLiquidContainerMed(med)) {
|
||||
if (doses && doses.length > 0) {
|
||||
@@ -401,8 +393,7 @@ export function SharedSchedule() {
|
||||
fetchData();
|
||||
}, [token, t]);
|
||||
|
||||
// Build schedule from medications - matches buildSchedulePreview logic exactly
|
||||
const schedule = useMemo(() => {
|
||||
function buildGroupedSchedule() {
|
||||
if (!data) return [];
|
||||
|
||||
// Use same logic as buildSchedulePreview in main app
|
||||
@@ -418,7 +409,7 @@ export function SharedSchedule() {
|
||||
when: number;
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||
intakeUnit?: IntakeUnit | null;
|
||||
timeStr: string;
|
||||
isPast: boolean;
|
||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||
@@ -426,15 +417,7 @@ export function SharedSchedule() {
|
||||
}[] = [];
|
||||
|
||||
for (const med of data.medications) {
|
||||
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
||||
const intakes =
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
intakeUnit: null,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}));
|
||||
const intakes = getMedicationIntakes(med);
|
||||
|
||||
intakes.forEach((intake, intakeIdx) => {
|
||||
// Filter: for person-specific shares, include matching intakes plus shared-for-everyone intakes.
|
||||
@@ -443,9 +426,7 @@ export function SharedSchedule() {
|
||||
const startDate = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(startDate.getTime())) return;
|
||||
|
||||
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
|
||||
// This ensures identical timestamps even across DST changes
|
||||
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
|
||||
iterateIntakeOccurrences(intake, startDate, end, (d) => {
|
||||
const t = d.getTime();
|
||||
const isPast = d < todayStart;
|
||||
// Use date-only timestamp for stable ID (immune to time changes)
|
||||
@@ -470,7 +451,7 @@ export function SharedSchedule() {
|
||||
month: "short",
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,6 +495,11 @@ export function SharedSchedule() {
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Visible schedule respects share-person filtering.
|
||||
const schedule = useMemo(() => {
|
||||
return buildGroupedSchedule();
|
||||
}, [data, i18n.language]);
|
||||
|
||||
// Split into past, today, and future - matches main app logic
|
||||
@@ -539,20 +525,12 @@ export function SharedSchedule() {
|
||||
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
|
||||
const coverageByMed = useMemo(() => {
|
||||
if (!data) return {};
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
const calcMode = data.stockCalculationMode ?? "automatic";
|
||||
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
|
||||
|
||||
for (const med of data.medications) {
|
||||
const intakes =
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
intakeUnit: null,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}));
|
||||
const intakes = getMedicationIntakes(med);
|
||||
|
||||
// Count unique people from all intakes (for per-intake takenBy)
|
||||
const uniquePeople = new Set<string>();
|
||||
@@ -566,7 +544,7 @@ export function SharedSchedule() {
|
||||
let dailyRate = 0;
|
||||
intakes.forEach((intake) => {
|
||||
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||
const baseRate = usageForStock * getIntakeDailyRate(intake);
|
||||
if (intake?.takenBy) {
|
||||
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
||||
} else {
|
||||
@@ -581,18 +559,8 @@ export function SharedSchedule() {
|
||||
// Time-based: every scheduled dose counts as consumed once its time has passed
|
||||
intakes.forEach((intake, blisterIdx) => {
|
||||
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||
const blisterStart = parseLocalDateTime(intake.start).getTime();
|
||||
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
if (Number.isNaN(effectiveStart)) return;
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(intakeStart.getTime())) return;
|
||||
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||
@@ -601,16 +569,15 @@ export function SharedSchedule() {
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
iterateIntakeOccurrences(intake, intakeStart, new Date(now), (occurrence) => {
|
||||
if (occurrence.getTime() <= stockCorrectionCutoff) return;
|
||||
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
occurrence.getFullYear(),
|
||||
occurrence.getMonth(),
|
||||
occurrence.getDate()
|
||||
).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
// Early intakes: future doses already marked as taken
|
||||
const stockCorrectionDateOnly =
|
||||
@@ -676,26 +643,42 @@ export function SharedSchedule() {
|
||||
return coverage;
|
||||
}, [data, takenDoses]);
|
||||
|
||||
const outOfStockMedicationIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(data?.medications ?? [])
|
||||
.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0)
|
||||
.map((med) => med.id)
|
||||
),
|
||||
[data, coverageByMed]
|
||||
);
|
||||
const sharedStockThresholds = useMemo<StockThresholds | null>(() => {
|
||||
if (!data?.stockThresholds) return null;
|
||||
return {
|
||||
lowStockDays: data.stockThresholds.lowStockDays,
|
||||
normalStockDays: data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays,
|
||||
highStockDays:
|
||||
data.stockThresholds.highStockDays ??
|
||||
Math.max(
|
||||
(data.stockThresholds.normalStockDays ?? data.stockThresholds.lowStockDays) + 1,
|
||||
data.stockThresholds.lowStockDays + 1
|
||||
),
|
||||
criticalStockDays:
|
||||
data.stockThresholds.reminderDaysBefore ?? Math.max(1, Math.ceil(data.stockThresholds.lowStockDays / 2)),
|
||||
expiryWarningDays: data.stockThresholds.expiryWarningDays ?? 30,
|
||||
};
|
||||
}, [data?.stockThresholds]);
|
||||
|
||||
const isDoseTakenForDisplay = useCallback(
|
||||
(doseId: string) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
|
||||
return false;
|
||||
const medicationOverviewByName = useMemo(() => {
|
||||
const overview = new Map<string, NonNullable<SharedScheduleData["medicationOverview"]>[number]>();
|
||||
for (const item of data?.medicationOverview ?? []) {
|
||||
overview.set(item.name, item);
|
||||
}
|
||||
return overview;
|
||||
}, [data?.medicationOverview]);
|
||||
|
||||
const emptyByOverviewName = useMemo(() => {
|
||||
const emptyNames = new Set<string>();
|
||||
for (const item of data?.medicationOverview ?? []) {
|
||||
if ((item.currentStock ?? 0) <= 0) {
|
||||
emptyNames.add(item.name);
|
||||
}
|
||||
return takenDoses.has(doseId);
|
||||
},
|
||||
[outOfStockMedicationIds, takenDoses]
|
||||
);
|
||||
}
|
||||
return emptyNames;
|
||||
}, [data?.medicationOverview]);
|
||||
|
||||
const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
|
||||
|
||||
const showMedicationOverview = data?.shareMedicationOverview === true && data?.medicationOverview !== null;
|
||||
const showOnlyToday = data?.shareScheduleTodayOnly === true;
|
||||
@@ -706,7 +689,7 @@ export function SharedSchedule() {
|
||||
|
||||
const renderDoseUsage = (
|
||||
med: SharedScheduleData["medications"][number] | undefined,
|
||||
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
|
||||
dose: { usage: number; intakeUnit?: IntakeUnit | null }
|
||||
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
|
||||
|
||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||
@@ -942,10 +925,35 @@ export function SharedSchedule() {
|
||||
day.meds.map((item) => {
|
||||
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const isEmpty =
|
||||
emptyByOverviewName.has(item.medName) ||
|
||||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
|
||||
const medOverview = medicationOverviewByName.get(item.medName);
|
||||
let stockStatus = null;
|
||||
if (!isEmpty && sharedStockThresholds) {
|
||||
if (medOverview && medOverview.currentStock !== null) {
|
||||
stockStatus = getStockStatus(
|
||||
medOverview.daysLeft,
|
||||
medOverview.currentStock,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
} else if (medCoverage) {
|
||||
stockStatus = getStockStatus(
|
||||
medCoverage.daysLeft,
|
||||
medCoverage.medsLeft,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
}
|
||||
}
|
||||
const isLowStock = stockStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -972,6 +980,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -979,8 +988,12 @@ export function SharedSchedule() {
|
||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const doseClasses = ["dose-item", "past"];
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
@@ -1128,9 +1141,34 @@ export function SharedSchedule() {
|
||||
day.meds.map((item) => {
|
||||
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const isEmpty =
|
||||
emptyByOverviewName.has(item.medName) ||
|
||||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
|
||||
const medOverview = medicationOverviewByName.get(item.medName);
|
||||
let stockStatus = null;
|
||||
if (!isEmpty && sharedStockThresholds) {
|
||||
if (medOverview && medOverview.currentStock !== null) {
|
||||
stockStatus = getStockStatus(
|
||||
medOverview.daysLeft,
|
||||
medOverview.currentStock,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
} else if (medCoverage) {
|
||||
stockStatus = getStockStatus(
|
||||
medCoverage.daysLeft,
|
||||
medCoverage.medsLeft,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
}
|
||||
}
|
||||
const isLowStock = stockStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1157,6 +1195,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -1165,11 +1204,13 @@ export function SharedSchedule() {
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
||||
const doseClasses = ["dose-item"];
|
||||
if (isOverdue) doseClasses.push("overdue");
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div
|
||||
key={dose.id}
|
||||
className={`dose-item ${isOverdue ? "overdue" : ""} ${isTaken ? "all-taken" : ""}`}
|
||||
>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
@@ -1302,9 +1343,34 @@ export function SharedSchedule() {
|
||||
day.meds.map((item) => {
|
||||
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const isEmpty =
|
||||
emptyByOverviewName.has(item.medName) ||
|
||||
(medCoverage ? medCoverage.medsLeft <= 0 : false);
|
||||
const medOverview = medicationOverviewByName.get(item.medName);
|
||||
let stockStatus = null;
|
||||
if (!isEmpty && sharedStockThresholds) {
|
||||
if (medOverview && medOverview.currentStock !== null) {
|
||||
stockStatus = getStockStatus(
|
||||
medOverview.daysLeft,
|
||||
medOverview.currentStock,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
} else if (medCoverage) {
|
||||
stockStatus = getStockStatus(
|
||||
medCoverage.daysLeft,
|
||||
medCoverage.medsLeft,
|
||||
sharedStockThresholds,
|
||||
med?.packageType
|
||||
);
|
||||
}
|
||||
}
|
||||
const isLowStock = stockStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1331,6 +1397,7 @@ export function SharedSchedule() {
|
||||
<span className="tag subtle">
|
||||
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||
</span>
|
||||
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
@@ -1338,8 +1405,12 @@ export function SharedSchedule() {
|
||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||
const doseClasses = ["dose-item", "future"];
|
||||
if (isTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -40,19 +42,9 @@ export function UserFilterModal({
|
||||
);
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatIntakeUsageLabel = (
|
||||
med: Medication,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
): string => {
|
||||
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
|
||||
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
|
||||
}
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
|
||||
@@ -111,14 +103,9 @@ export function UserFilterModal({
|
||||
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
|
||||
const personIntakes = getMedicationIntakes(med).filter(
|
||||
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -146,7 +133,7 @@ export function UserFilterModal({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}`;
|
||||
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
|
||||
return (
|
||||
<span key={intakeKey} className="user-med-intake-item">
|
||||
@@ -154,8 +141,7 @@ export function UserFilterModal({
|
||||
{allowsPillFormSelection(med.packageType) &&
|
||||
med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||
{t("modal.at")} {timeStr}
|
||||
{getIntakeFrequencyText(intake, t)} {t("modal.at")} {timeStr}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type { MedDetailModalProps } from "./MedDetailModal";
|
||||
export { MedDetailModal } from "./MedDetailModal";
|
||||
export type { MedicationAvatarProps } from "./MedicationAvatar";
|
||||
export { MedicationAvatar } from "./MedicationAvatar";
|
||||
export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSection";
|
||||
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
|
||||
export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export { PasswordInput } from "./PasswordInput";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizePackageType,
|
||||
} from "../types";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
import { normalizeWeekdays } from "../utils/intake-schedule";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
@@ -30,6 +31,8 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
|
||||
every: "1",
|
||||
startDate: toDateValue(now),
|
||||
startTime: toTimeValue(now),
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: "ml",
|
||||
takenBy, // Per-intake user assignment (empty string = null/everyone)
|
||||
intakeRemindersEnabled: false,
|
||||
@@ -93,7 +96,7 @@ export interface UseMedicationFormReturn {
|
||||
addBlister: () => void;
|
||||
removeBlister: (idx: number) => void;
|
||||
// Intake management with per-intake takenBy
|
||||
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
|
||||
setIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
|
||||
addIntake: (takenBy?: string) => void;
|
||||
removeIntake: (idx: number) => void;
|
||||
startEdit: (med: Medication, openEditModal: () => void) => void;
|
||||
@@ -189,7 +192,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
}, []);
|
||||
|
||||
// Intake management with per-intake takenBy
|
||||
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
|
||||
const setIntakeValue = useCallback(<K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => {
|
||||
setForm((prev) => {
|
||||
const next = [...prev.intakes];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
@@ -219,6 +222,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(i.every),
|
||||
startDate: toDateValue(i.start),
|
||||
startTime: toTimeValue(i.start),
|
||||
scheduleMode: (i.scheduleMode === "weekdays" ? "weekdays" : "interval") as FormIntake["scheduleMode"],
|
||||
weekdays: normalizeWeekdays(i.weekdays),
|
||||
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
|
||||
takenBy: i.takenBy ?? "", // Convert null to empty string for form
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled,
|
||||
@@ -228,6 +233,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
every: String(s.every),
|
||||
startDate: toDateValue(s.start),
|
||||
startTime: toTimeValue(s.start),
|
||||
scheduleMode: "interval" as const,
|
||||
weekdays: [],
|
||||
intakeUnit: "ml" as const,
|
||||
takenBy: "", // Legacy blisters have no per-intake takenBy
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
|
||||
@@ -225,6 +225,50 @@
|
||||
"weight": "z.B. 240",
|
||||
"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": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen.",
|
||||
"endDateBeforeStart": "Das Medikations-Enddatum ({{medicationEndDate}}) darf nicht vor dem Startdatum ({{medicationStartDate}}) liegen."
|
||||
@@ -257,8 +301,31 @@
|
||||
"applications": "Anwendungen",
|
||||
"applications_one": "Anwendung",
|
||||
"applications_other": "Anwendungen",
|
||||
"scheduleMode": "Planmodus",
|
||||
"scheduleModeInterval": "Alle X Tage",
|
||||
"scheduleModeWeekdays": "Bestimmte Wochentage",
|
||||
"everyDays": "Alle (Tage)",
|
||||
"every": "alle",
|
||||
"weekdays": "Wochentage",
|
||||
"weekdaysRequired": "Waehle mindestens einen Wochentag aus",
|
||||
"weekdaysShort": {
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So"
|
||||
},
|
||||
"weekdaysLong": {
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag",
|
||||
"sun": "Sonntag"
|
||||
},
|
||||
"from": "ab",
|
||||
"startDate": "Datum",
|
||||
"startTime": "Uhrzeit",
|
||||
@@ -314,7 +381,7 @@
|
||||
"prescriptionReminders": "Rezept-Erinnerungen",
|
||||
"enableHint": "Aktiviere mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden. Wenn diese Option und die automatische Einnahme gleichzeitig aktiviert sind, werden gar keine Einnahme-Erinnerungen mehr gesendet.",
|
||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||
"repeatRemindersTooltip": "Sende automatisch wiederholte Erinnerungen für Dosen, die noch nicht als genommen markiert wurden",
|
||||
"reminderInterval": "Erinnerungsintervall (Minuten)",
|
||||
|
||||
@@ -225,6 +225,50 @@
|
||||
"weight": "e.g. 240",
|
||||
"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": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}}).",
|
||||
"endDateBeforeStart": "Medication end date ({{medicationEndDate}}) cannot be before medication start date ({{medicationStartDate}})."
|
||||
@@ -257,8 +301,31 @@
|
||||
"applications": "applications",
|
||||
"applications_one": "application",
|
||||
"applications_other": "applications",
|
||||
"scheduleMode": "Schedule mode",
|
||||
"scheduleModeInterval": "Every X days",
|
||||
"scheduleModeWeekdays": "Specific weekdays",
|
||||
"everyDays": "Every (days)",
|
||||
"every": "every",
|
||||
"weekdays": "Weekdays",
|
||||
"weekdaysRequired": "Select at least one weekday",
|
||||
"weekdaysShort": {
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"weekdaysLong": {
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"from": "from",
|
||||
"startDate": "Date",
|
||||
"startTime": "Time",
|
||||
@@ -314,7 +381,7 @@
|
||||
"prescriptionReminders": "Prescription Reminders",
|
||||
"enableHint": "Enable at least one channel below to receive notifications.",
|
||||
"skipTakenDoses": "Skip reminders for taken doses",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today",
|
||||
"skipTakenDosesTooltip": "Don't send intake reminders for doses that have already been marked as taken today. If this option and automatic intake are both enabled, no intake reminders will be sent.",
|
||||
"repeatReminders": "Repeat reminders for missed doses",
|
||||
"repeatRemindersTooltip": "Automatically send repeated reminders for doses that haven't been marked as taken",
|
||||
"reminderInterval": "Reminder interval (minutes)",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||
import { Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
allowsPillFormSelection,
|
||||
type Coverage,
|
||||
getMedDisplayName,
|
||||
type IntakeUnit,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import { getIntakeDailyRate, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { buildClearMissedPayload, expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import {
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
@@ -85,18 +88,10 @@ export function DashboardPage() {
|
||||
} = useAppContext();
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
const outOfStockMedicationIds = new Set(
|
||||
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
|
||||
);
|
||||
|
||||
const isDoseTakenForDisplay = (doseId: string) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
|
||||
return false;
|
||||
}
|
||||
return takenDoses.has(doseId);
|
||||
};
|
||||
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
||||
|
||||
// Get structured reminder data
|
||||
const reminderData = getReminderStatusData(
|
||||
@@ -149,41 +144,8 @@ export function DashboardPage() {
|
||||
|
||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
@@ -210,6 +172,32 @@ export function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmMarkObsolete = async () => {
|
||||
if (!obsoleteCandidate) return;
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await loadMeds();
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMarkObsolete = () => {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
};
|
||||
|
||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
@@ -227,19 +215,7 @@ export function DashboardPage() {
|
||||
return t("table.pillsCount", { count: Math.round(medsLeft) });
|
||||
};
|
||||
|
||||
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||
if (unit === "tsp") return usage * 5;
|
||||
if (unit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
|
||||
const normalizedUsage = Number(usage);
|
||||
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||
@@ -250,13 +226,13 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
intakeUnit?: IntakeUnit | null
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||
@@ -270,8 +246,8 @@ export function DashboardPage() {
|
||||
const formatTotalUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
total: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null,
|
||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||
intakeUnit?: IntakeUnit | null,
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
if (doses && doses.length > 0) {
|
||||
@@ -304,27 +280,18 @@ export function DashboardPage() {
|
||||
const formatDailyConsumption = (med: (typeof meds)[number] | undefined) => {
|
||||
if (!med) return "-";
|
||||
|
||||
const intakes =
|
||||
med.intakes && med.intakes.length > 0
|
||||
? med.intakes
|
||||
: med.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
intakeUnit: null as "ml" | "tsp" | "tbsp" | null,
|
||||
takenBy: null as string | null,
|
||||
}));
|
||||
const intakes = getMedicationIntakes(med);
|
||||
|
||||
if (intakes.length === 0) return "-";
|
||||
|
||||
let dailyTotal = 0;
|
||||
for (const intake of intakes) {
|
||||
const usage = Number(intake.usage);
|
||||
const every = Math.max(1, Number(intake.every) || 1);
|
||||
if (!Number.isFinite(usage) || usage <= 0) continue;
|
||||
|
||||
const hasPerIntakeTakenBy = typeof intake.takenBy === "string" && intake.takenBy.trim().length > 0;
|
||||
const personMultiplier = hasPerIntakeTakenBy ? 1 : Math.max(1, med.takenBy?.length ?? 0);
|
||||
const normalizedUsage = (usage * personMultiplier) / every;
|
||||
const normalizedUsage = usage * personMultiplier * getIntakeDailyRate(intake);
|
||||
|
||||
if (isLiquidContainerPackageType(med.packageType)) {
|
||||
dailyTotal += convertLiquidUsageToMl(normalizedUsage, intake.intakeUnit ?? "ml");
|
||||
@@ -1034,8 +1001,12 @@ export function DashboardPage() {
|
||||
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
|
||||
: null;
|
||||
const status = getVisibleStockStatus(med, rawStatus);
|
||||
const isLowStock = !isEmpty && status?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1082,8 +1053,12 @@ export function DashboardPage() {
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
const doseClasses = ["dose-item", "past"];
|
||||
if (allTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -1251,6 +1226,17 @@ export function DashboardPage() {
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{showObsoleteConfirm && obsoleteCandidate && (
|
||||
<ConfirmModal
|
||||
title={t("medications.obsoleteModal.title")}
|
||||
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
|
||||
confirmLabel={t("medications.list.markObsolete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={() => void handleConfirmMarkObsolete()}
|
||||
onCancel={handleCancelMarkObsolete}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{/* Today - always visible */}
|
||||
{todayDay &&
|
||||
(() => {
|
||||
@@ -1329,8 +1315,12 @@ export function DashboardPage() {
|
||||
)
|
||||
: null;
|
||||
const visibleStatus = getVisibleStockStatus(med, status);
|
||||
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1371,6 +1361,20 @@ export function DashboardPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && med && !med.isObsolete && (
|
||||
<div className="timeline-obsolete-row">
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-obsolete-btn btn-obsolete"
|
||||
onClick={() =>
|
||||
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
|
||||
}
|
||||
>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
<span>{t("medications.list.markObsolete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
@@ -1379,11 +1383,13 @@ export function DashboardPage() {
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
const doseClasses = ["dose-item"];
|
||||
if (isOverdue) doseClasses.push("overdue");
|
||||
if (allTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div
|
||||
key={dose.id}
|
||||
className={`dose-item ${isOverdue ? "overdue" : ""} ${allTaken ? "all-taken" : ""}`}
|
||||
>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -1569,7 +1575,7 @@ export function DashboardPage() {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
@@ -1582,8 +1588,12 @@ export function DashboardPage() {
|
||||
)
|
||||
: null;
|
||||
const visibleStatus = getVisibleStockStatus(med, status);
|
||||
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1624,6 +1634,20 @@ export function DashboardPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && med && !med.isObsolete && (
|
||||
<div className="timeline-obsolete-row">
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-obsolete-btn btn-obsolete"
|
||||
onClick={() =>
|
||||
requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })
|
||||
}
|
||||
>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
<span>{t("medications.list.markObsolete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
@@ -1631,8 +1655,12 @@ export function DashboardPage() {
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
const doseClasses = ["dose-item", "future"];
|
||||
if (allTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
|
||||
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
|
||||
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { Archive, Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
@@ -11,13 +11,23 @@ import {
|
||||
FormNumberStepper,
|
||||
Lightbox,
|
||||
MedicationAvatar,
|
||||
MedicationEnrichmentSection,
|
||||
MobileEditModal,
|
||||
ReportModal,
|
||||
} from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
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 {
|
||||
allowsPillFormSelection,
|
||||
DOSE_UNITS,
|
||||
@@ -33,6 +43,15 @@ import {
|
||||
} from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import {
|
||||
getIntakeFrequencyText,
|
||||
getIntakeScheduleMode,
|
||||
getMedicationIntakes,
|
||||
getWeekdayLabel,
|
||||
hasSelectedWeekdays,
|
||||
toggleWeekdaySelection,
|
||||
WEEKDAY_CODES,
|
||||
} from "../utils/intake-schedule";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -40,6 +59,113 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
}
|
||||
|
||||
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() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -153,6 +279,88 @@ export function MedicationsPage() {
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||
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(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -249,6 +457,126 @@ export function MedicationsPage() {
|
||||
[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
|
||||
const totalTablets = useMemo(() => {
|
||||
if (isAmountBasedPackageType(form.packageType)) {
|
||||
@@ -311,6 +639,24 @@ export function MedicationsPage() {
|
||||
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
|
||||
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
|
||||
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
|
||||
const weekdayOptions = useMemo(
|
||||
() =>
|
||||
WEEKDAY_CODES.map((day) => ({
|
||||
value: day,
|
||||
shortLabel: getWeekdayLabel(day, t, "short"),
|
||||
longLabel: getWeekdayLabel(day, t, "long"),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const hasWeekdaySelectionError = useCallback(
|
||||
(intake: (typeof form.intakes)[number]) =>
|
||||
getIntakeScheduleMode(intake) === "weekdays" && !hasSelectedWeekdays(intake.weekdays),
|
||||
[]
|
||||
);
|
||||
const hasWeekdayScheduleError = useMemo(
|
||||
() => form.intakes.some((intake) => hasWeekdaySelectionError(intake)),
|
||||
[form.intakes, hasWeekdaySelectionError]
|
||||
);
|
||||
|
||||
const getMedicationPackageTypeLabel = useCallback(
|
||||
(med: Medication) => {
|
||||
@@ -389,12 +735,14 @@ export function MedicationsPage() {
|
||||
if (pendingAction) {
|
||||
// There's a pending action (e.g. switching to another medication) — reset and run it
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setReadOnlyView(false);
|
||||
pendingAction();
|
||||
} else if (source === "mobile-edit" && showEditModal) {
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setReadOnlyView(false);
|
||||
window.history.back();
|
||||
} else {
|
||||
@@ -422,6 +770,7 @@ export function MedicationsPage() {
|
||||
window.history.back();
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setActiveTab("general");
|
||||
setReadOnlyView(false);
|
||||
@@ -512,7 +861,7 @@ export function MedicationsPage() {
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (readOnlyView) return;
|
||||
if (hasValidationErrors || dateConsistencyError) {
|
||||
if (hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError) {
|
||||
setShowNameValidation(true);
|
||||
// Scroll to first visible error so the user sees what's wrong
|
||||
const firstError = document.querySelector(".field-error");
|
||||
@@ -534,8 +883,10 @@ export function MedicationsPage() {
|
||||
// Prepare intakes data with per-intake takenBy
|
||||
const intakes = form.intakes.map((intake) => ({
|
||||
usage: Number(intake.usage) || 1,
|
||||
every: Number(intake.every) || 1,
|
||||
every: getIntakeScheduleMode(intake) === "weekdays" ? 1 : Number(intake.every) || 1,
|
||||
start: combineDateAndTime(intake.startDate, intake.startTime),
|
||||
scheduleMode: getIntakeScheduleMode(intake),
|
||||
weekdays: getIntakeScheduleMode(intake) === "weekdays" ? [...(intake.weekdays ?? [])] : [],
|
||||
intakeUnit: isLiquidContainerPackageType(form.packageType) ? intake.intakeUnit : null,
|
||||
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
@@ -681,11 +1032,13 @@ export function MedicationsPage() {
|
||||
setActiveTab("general");
|
||||
setViewMode("grid");
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
window.history.back();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setViewMode("grid");
|
||||
} else {
|
||||
// Update originalForm so formChanged becomes false
|
||||
@@ -729,6 +1082,7 @@ export function MedicationsPage() {
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -751,6 +1105,7 @@ export function MedicationsPage() {
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -764,6 +1119,7 @@ export function MedicationsPage() {
|
||||
}
|
||||
hasDesktopFormHistoryState.current = false;
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setActiveTab("general");
|
||||
setReadOnlyView(false);
|
||||
@@ -807,6 +1163,7 @@ export function MedicationsPage() {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -818,6 +1175,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -828,6 +1186,7 @@ export function MedicationsPage() {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -839,6 +1198,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(med.name || med.genericName || "");
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -848,6 +1208,7 @@ export function MedicationsPage() {
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -861,6 +1222,7 @@ export function MedicationsPage() {
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -903,6 +1265,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
@@ -979,6 +1342,16 @@ export function MedicationsPage() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-obsolete"
|
||||
onClick={() => requestMarkObsolete(med)}
|
||||
aria-label={t("medications.list.markObsolete")}
|
||||
>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
<span>{t("medications.list.markObsolete")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => requestDeleteMed(med)}
|
||||
aria-label={t("common.delete")}
|
||||
@@ -986,9 +1359,6 @@ export function MedicationsPage() {
|
||||
>
|
||||
<Trash2 size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
|
||||
{t("medications.list.markObsolete")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="med-details">
|
||||
<span>
|
||||
@@ -1043,15 +1413,12 @@ export function MedicationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="blister-list">
|
||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||
{getMedicationIntakes(med).map((s, idx) => (
|
||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} ·{" "}
|
||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{s.usage} {getMedicationUsageUnitLabel(med, s.usage)} · {getIntakeFrequencyText(s, t)} ·{" "}
|
||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||
)}
|
||||
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
|
||||
{s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||
{s.intakeRemindersEnabled && (
|
||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||
{" "}
|
||||
<Bell size={12} aria-hidden="true" />
|
||||
@@ -1247,6 +1614,14 @@ export function MedicationsPage() {
|
||||
<span className="field-error">{fieldErrors.genericName}</span>
|
||||
)}
|
||||
</label>
|
||||
<MedicationEnrichmentSection
|
||||
state={medicationEnrichmentViewModel}
|
||||
onQueryChange={handleMedicationEnrichmentQueryChange}
|
||||
onSearch={handleMedicationEnrichmentSearch}
|
||||
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
|
||||
onApplyResult={handleMedicationEnrichmentApply}
|
||||
onApplyStrength={handleMedicationEnrichmentStrengthApply}
|
||||
/>
|
||||
<div className="full date-pair-group">
|
||||
<label className="date-pair-field">
|
||||
{t("form.medicationStartDate")}
|
||||
@@ -1727,105 +2102,154 @@ export function MedicationsPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startDate")}
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startTime")}
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
{form.intakes.map((intake, idx) => {
|
||||
const scheduleMode = getIntakeScheduleMode(intake);
|
||||
const selectedWeekdays = intake.weekdays ?? [];
|
||||
return (
|
||||
<div key={idx} className="blister-row">
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
{getUsageLabel(intake.intakeUnit ?? "ml")}
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={allowFractionalIntake ? 0.5 : 1}
|
||||
step={allowFractionalIntake ? 0.5 : 1}
|
||||
allowDecimal={allowFractionalIntake}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.scheduleMode")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
value={scheduleMode}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
setIntakeValue(idx, "scheduleMode", e.target.value as "interval" | "weekdays")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
<option value="interval">{t("form.blisters.scheduleModeInterval")}</option>
|
||||
<option value="weekdays">{t("form.blisters.scheduleModeWeekdays")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="blister-reminder-icon">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
{scheduleMode === "interval" ? (
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="taken-by-field">
|
||||
{t("form.blisters.weekdays")}
|
||||
<div className="badges">
|
||||
{weekdayOptions.map((weekday) => {
|
||||
const isSelected = selectedWeekdays.includes(weekday.value);
|
||||
return (
|
||||
<button
|
||||
key={weekday.value}
|
||||
type="button"
|
||||
className={isSelected ? "pill clickable" : "pill clickable neutral"}
|
||||
aria-pressed={isSelected}
|
||||
title={weekday.longLabel}
|
||||
onClick={() =>
|
||||
setIntakeValue(
|
||||
idx,
|
||||
"weekdays",
|
||||
toggleWeekdaySelection(selectedWeekdays, weekday.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
{weekday.shortLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!readOnlyView && hasWeekdaySelectionError(intake) && (
|
||||
<span className="field-error">{t("form.blisters.weekdaysRequired")}</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{t("form.blisters.startDate")}
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.startTime")}
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label>
|
||||
{t("form.blisters.intakeUnit")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
setIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
}
|
||||
>
|
||||
<option value="ml">{t("form.blisters.intakeUnitMl")}</option>
|
||||
<option value="tsp">{t("form.blisters.intakeUnitTsp")}</option>
|
||||
<option value="tbsp">{t("form.blisters.intakeUnitTbsp")}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||
{t("form.blisters.takenByIntake")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="blister-reminder-icon">
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnlyView && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => removeIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!readOnlyView && form.intakes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => removeIntake(idx)}
|
||||
aria-label={t("common.remove")}
|
||||
data-tooltip={t("common.remove")}
|
||||
>
|
||||
<Minus size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* end schedule tab */}
|
||||
@@ -1838,7 +2262,9 @@ export function MedicationsPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || (!formChanged && (formSaved || !!editingId))}
|
||||
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
|
||||
className={
|
||||
hasValidationErrors || dateConsistencyError || hasWeekdayScheduleError ? "has-validation-error" : ""
|
||||
}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
@@ -1854,6 +2280,12 @@ export function MedicationsPage() {
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
medicationEnrichment={medicationEnrichmentViewModel}
|
||||
onMedicationEnrichmentQueryChange={handleMedicationEnrichmentQueryChange}
|
||||
onMedicationEnrichmentSearch={handleMedicationEnrichmentSearch}
|
||||
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
|
||||
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
|
||||
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
|
||||
fieldErrors={fieldErrors}
|
||||
saving={saving}
|
||||
formSaved={formSaved}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||
import { Bell } from "lucide-react";
|
||||
import { Archive, Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { Coverage } from "../types";
|
||||
import type { Coverage, IntakeUnit } from "../types";
|
||||
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { formatNumber } from "../utils/formatters";
|
||||
import { isDoseDismissed } from "../utils/schedule";
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -95,59 +96,18 @@ export function SchedulePage() {
|
||||
} = useAppContext();
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
const outOfStockMedicationIds = new Set(
|
||||
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
|
||||
);
|
||||
|
||||
const isDoseTakenForDisplay = (doseId: string) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
|
||||
return false;
|
||||
}
|
||||
return takenDoses.has(doseId);
|
||||
};
|
||||
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
||||
|
||||
const shouldHideNoScheduleStatusForTube = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
status: { className: string; label: string } | null
|
||||
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
const payload = buildClearMissedPayload(pastDays, meds, takenDoses, dismissedDoses);
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
@@ -174,24 +134,38 @@ export function SchedulePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmMarkObsolete = async () => {
|
||||
if (!obsoleteCandidate) return;
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await loadMeds();
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMarkObsolete = () => {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
};
|
||||
|
||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
: t("form.blisters.applications", { count: Math.abs(value) });
|
||||
|
||||
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||
if (unit === "tsp") return usage * 5;
|
||||
if (unit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
|
||||
const normalizedUsage = Number(usage);
|
||||
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||
@@ -202,13 +176,13 @@ export function SchedulePage() {
|
||||
}
|
||||
|
||||
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
return `${formatNumber(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatNumber(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
intakeUnit?: IntakeUnit | null
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||
@@ -222,7 +196,7 @@ export function SchedulePage() {
|
||||
const formatTotalUsageLabel = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
total: number,
|
||||
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => {
|
||||
if (isLiquidContainerPackageType(med?.packageType)) {
|
||||
if (doses && doses.length > 0) {
|
||||
@@ -345,8 +319,16 @@ export function SchedulePage() {
|
||||
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const rawStatus = medCov
|
||||
? getStockStatus(medCov.daysLeft, medCov.medsLeft, settings, med?.packageType)
|
||||
: null;
|
||||
const visibleStatus = shouldHideNoScheduleStatusForTube(med, rawStatus) ? null : rawStatus;
|
||||
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
@@ -363,8 +345,12 @@ export function SchedulePage() {
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
const doseClasses = ["dose-item", "past"];
|
||||
if (allTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -517,6 +503,17 @@ export function SchedulePage() {
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{showObsoleteConfirm && obsoleteCandidate && (
|
||||
<ConfirmModal
|
||||
title={t("medications.obsoleteModal.title")}
|
||||
message={t("medications.obsoleteModal.message", { name: obsoleteCandidate.name })}
|
||||
confirmLabel={t("medications.list.markObsolete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={() => void handleConfirmMarkObsolete()}
|
||||
onCancel={handleCancelMarkObsolete}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
const today = new Date();
|
||||
@@ -540,8 +537,12 @@ export function SchedulePage() {
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
|
||||
: null;
|
||||
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
|
||||
const isLowStock = !isEmpty && visibleStatus?.className === "warning";
|
||||
const rowClasses = ["time-row"];
|
||||
if (isEmpty) rowClasses.push("med-empty");
|
||||
else if (isLowStock) rowClasses.push("med-low");
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
@@ -553,6 +554,18 @@ export function SchedulePage() {
|
||||
<span className={`tag ${visibleStatus.className}`}>{t(visibleStatus.label)}</span>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && med && !med.isObsolete && (
|
||||
<div className="timeline-obsolete-row">
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-obsolete-btn btn-obsolete"
|
||||
onClick={() => requestMarkObsolete({ id: med.id, name: getMedDisplayName(med) })}
|
||||
>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
<span>{t("medications.list.markObsolete")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
@@ -561,8 +574,12 @@ export function SchedulePage() {
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)));
|
||||
const doseClasses = ["dose-item"];
|
||||
if (allTaken) doseClasses.push("all-taken");
|
||||
if (isEmpty) doseClasses.push("med-empty");
|
||||
else if (isLowStock) doseClasses.push("med-low");
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
|
||||
<div key={dose.id} className={doseClasses.join(" ")}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
|
||||
+524
-166
@@ -1,3 +1,4 @@
|
||||
/* biome-ignore-all lint/style/noDescendingSpecificity: legacy shared stylesheet relies on intentional cascade ordering across base and contextual selectors */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
@@ -577,25 +578,6 @@ body.modal-open {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.tabs .pill {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tabs .pill:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tabs .pill.primary {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
@@ -732,6 +714,26 @@ body.modal-open {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tabs .pill {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tabs .pill:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tabs .pill.primary {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -804,10 +806,6 @@ body.modal-open {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.med-group-head-toggle:hover .med-group-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.med-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
@@ -817,6 +815,10 @@ body.modal-open {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.med-group-head-toggle:hover .med-group-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.med-group-obsolete {
|
||||
border-color: var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
@@ -832,17 +834,6 @@ body.modal-open {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.med-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1024,6 +1015,51 @@ body.modal-open {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline-obsolete-row {
|
||||
margin-top: 0.15rem;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.timeline-obsolete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
min-height: 2.5rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 1px solid #f8e38a;
|
||||
color: #ffd54a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.timeline-obsolete-btn:hover {
|
||||
background: color-mix(in srgb, #facc15 12%, transparent);
|
||||
border-color: #ffe27c;
|
||||
color: #ffe27c;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.timeline-obsolete-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-theme="light"] .timeline-obsolete-btn {
|
||||
background: transparent;
|
||||
border-color: #d97706;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
[data-theme="light"] .timeline-obsolete-btn:hover {
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
border-color: #b45309;
|
||||
color: #92400e;
|
||||
}
|
||||
.danger-text {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
@@ -1047,6 +1083,153 @@ body.modal-open {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: var(--btn-radius);
|
||||
border: none;
|
||||
background: var(--btn-primary-bg);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--btn-shadow);
|
||||
transition:
|
||||
background 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
button:active {
|
||||
box-shadow: var(--btn-shadow);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent-light);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button.icon-only {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button.icon-only svg,
|
||||
.modal-close svg,
|
||||
.btn-copy svg,
|
||||
.share-btn svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
stroke-width: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Secondary button (Edit, etc.) */
|
||||
button.secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
[data-theme="light"] button.secondary {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
[data-theme="light"] button.secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: var(--btn-success-text);
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
button.success:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary/Accent button (New entry, Add intake, etc.) */
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
button.primary:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Info button (Edit, secondary actions) */
|
||||
button.info {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.info:hover {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
/* Ghost button (Cancel, etc.) */
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
[data-theme="light"] button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
|
||||
/* Navigation button (Back): neutral and low visual urgency */
|
||||
button.btn-nav {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-nav:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Reversible status-change button (Mark obsolete): warning, not destructive */
|
||||
button.btn-obsolete {
|
||||
background: var(--btn-obsolete-bg);
|
||||
border: 1px solid var(--btn-obsolete-border);
|
||||
color: var(--btn-obsolete-text);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
button.btn-obsolete:hover {
|
||||
background: var(--btn-obsolete-hover);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-obsolete:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.med-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1102,6 +1285,8 @@ body.modal-open {
|
||||
color: #ffd54a;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-secondary);
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.med-actions button.btn-obsolete:hover {
|
||||
@@ -1353,152 +1538,43 @@ body.modal-open {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: var(--btn-radius);
|
||||
border: none;
|
||||
background: var(--btn-primary-bg);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--btn-shadow);
|
||||
transition:
|
||||
background 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
button:active {
|
||||
box-shadow: var(--btn-shadow);
|
||||
.obsolete-row .med-actions button {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent-light);
|
||||
outline-offset: 2px;
|
||||
.obsolete-row .med-actions button:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
button.icon-only {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button.icon-only svg,
|
||||
.modal-close svg,
|
||||
.btn-copy svg,
|
||||
.share-btn svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
stroke-width: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Secondary button (Edit, etc.) */
|
||||
button.secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
[data-theme="light"] button.secondary {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
[data-theme="light"] button.secondary:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: var(--btn-success-text);
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
button.success:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary/Accent button (New entry, Add intake, etc.) */
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
button.primary:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Info button (Edit, secondary actions) */
|
||||
button.info {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
button.info:hover {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
/* Ghost button (Cancel, etc.) */
|
||||
button.ghost {
|
||||
button.timeline-obsolete-btn.btn-obsolete {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid #f8e38a;
|
||||
color: #ffd54a;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
[data-theme="light"] button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
|
||||
/* Navigation button (Back): neutral and low visual urgency */
|
||||
button.btn-nav {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
button.timeline-obsolete-btn.btn-obsolete:hover {
|
||||
background: color-mix(in srgb, #facc15 12%, transparent);
|
||||
border-color: #ffe27c;
|
||||
color: #ffe27c;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-nav:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Reversible status-change button (Mark obsolete): warning, not destructive */
|
||||
button.btn-obsolete {
|
||||
background: var(--btn-obsolete-bg);
|
||||
border: 1px solid var(--btn-obsolete-border);
|
||||
color: var(--btn-obsolete-text);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete {
|
||||
background: transparent;
|
||||
border-color: #d97706;
|
||||
color: #b45309;
|
||||
}
|
||||
button.btn-obsolete:hover {
|
||||
background: var(--btn-obsolete-hover);
|
||||
transform: none;
|
||||
|
||||
[data-theme="light"] button.timeline-obsolete-btn.btn-obsolete:hover {
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
border-color: #b45309;
|
||||
color: #92400e;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-obsolete:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Danger button (Delete, etc.) */
|
||||
button.danger {
|
||||
@@ -1992,6 +2068,211 @@ button.has-validation-error {
|
||||
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,
|
||||
.dose-unit-select:hover {
|
||||
border-color: var(--accent);
|
||||
@@ -2392,6 +2673,42 @@ button.has-validation-error {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.time-row.med-empty {
|
||||
border-left: 3px solid var(--danger);
|
||||
padding-left: 0.6rem;
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.time-row.med-low {
|
||||
border-left: 3px solid var(--warning);
|
||||
padding-left: 0.6rem;
|
||||
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.time-row.med-low .med-name-text {
|
||||
color: color-mix(in srgb, var(--warning) 88%, white 12%);
|
||||
}
|
||||
|
||||
.time-row.med-low .tag.subtle {
|
||||
background: color-mix(in srgb, var(--warning) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 42%, transparent);
|
||||
color: color-mix(in srgb, var(--warning) 82%, white 18%);
|
||||
}
|
||||
|
||||
.time-row.med-empty .med-name-text {
|
||||
color: var(--danger);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.time-row.med-empty .tag.subtle {
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
|
||||
color: color-mix(in srgb, var(--danger) 82%, white 18%);
|
||||
}
|
||||
.time-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2521,6 +2838,47 @@ button.has-validation-error {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dose-item.med-low,
|
||||
.dose-item.med-low.all-taken,
|
||||
.dose-item.med-low.taken,
|
||||
.dose-item.med-low.future,
|
||||
.dose-item.med-low.overdue,
|
||||
.dose-item.med-low.overdue.taken {
|
||||
border-color: rgba(252, 211, 77, 0.5);
|
||||
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--warning) 88%, black 12%);
|
||||
}
|
||||
|
||||
.dose-item.med-low:not(.all-taken):not(.taken):not(.overdue):not(.med-empty) {
|
||||
background: color-mix(in srgb, var(--warning) 9%, var(--accent-bg));
|
||||
}
|
||||
|
||||
.dose-item.med-empty,
|
||||
.dose-item.med-empty.all-taken,
|
||||
.dose-item.med-empty.taken,
|
||||
.dose-item.med-empty.future,
|
||||
.dose-item.med-empty.overdue,
|
||||
.dose-item.med-empty.overdue.taken {
|
||||
background: color-mix(in srgb, var(--danger) 13%, transparent);
|
||||
border-color: color-mix(in srgb, var(--danger) 46%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dose-item.med-empty .dose-time,
|
||||
.dose-item.med-empty .dose-usage {
|
||||
color: color-mix(in srgb, var(--danger) 82%, white 18%);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.dose-item.med-empty .dose-person {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
}
|
||||
|
||||
.dose-item.med-empty .dose-person .person-name,
|
||||
.dose-item.med-empty .dose-person.taken .person-name {
|
||||
color: color-mix(in srgb, var(--danger) 80%, white 20%);
|
||||
}
|
||||
|
||||
.dose-time {
|
||||
font-weight: 600;
|
||||
color: var(--accent-light);
|
||||
|
||||
@@ -45,10 +45,41 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--success);
|
||||
border-radius: 6px;
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.refill-footer-right .refill-preview {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
/* Refill: submit row (button + pill preview) */
|
||||
.refill-submit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-submit-row button {
|
||||
height: 42px;
|
||||
padding: 0 2rem;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Refill modal footer mobile */
|
||||
@media (max-width: 640px) {
|
||||
.refill-modal .modal-footer {
|
||||
@@ -70,20 +101,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Refill: submit row (button + pill preview) */
|
||||
.refill-submit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-submit-row button {
|
||||
height: 42px;
|
||||
padding: 0 2rem;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Refill: prescription toggle row */
|
||||
.refill-prescription-row {
|
||||
display: flex;
|
||||
@@ -134,23 +151,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.refill-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--success);
|
||||
border-radius: 6px;
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.refill-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -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 type { FormEvent } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MedicationEnrichmentViewModel } from "../../components/MedicationEnrichmentSection";
|
||||
import { MobileEditModal } from "../../components/MobileEditModal";
|
||||
import type { FormState } from "../../types";
|
||||
import type { FormState, WeekdayCode } from "../../types";
|
||||
|
||||
const defaultForm: FormState = {
|
||||
name: "",
|
||||
@@ -92,6 +93,26 @@ const defaultProps = {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -161,6 +182,64 @@ describe("MobileEditModal", () => {
|
||||
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", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
@@ -429,6 +508,61 @@ describe("MobileEditModal blister management", () => {
|
||||
expect(onSetIntakeValue).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows weekday controls and validation error for weekday schedules", () => {
|
||||
const form = {
|
||||
...defaultForm,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: [],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} formChanged={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
|
||||
expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("toggles weekday selections for weekday schedules", () => {
|
||||
const onSetIntakeValue = vi.fn();
|
||||
const form = {
|
||||
...defaultForm,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed"] satisfies WeekdayCode[],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<MobileEditModal {...defaultProps} form={form} onSetIntakeValue={onSetIntakeValue} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon"));
|
||||
|
||||
expect(onSetIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MobileEditModal form submission", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ReportModal from "../../components/ReportModal";
|
||||
import type { Medication } from "../../types";
|
||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||
|
||||
function createMedication(overrides: Partial<Medication> = {}): Medication {
|
||||
return {
|
||||
@@ -65,6 +66,53 @@ describe("ReportModal", () => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders shared formatter output in exported text reports", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
medicationStartDate: "2026-02-01",
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-02-02T08:30:00.000Z" }],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
|
||||
const content = await (blob as Blob).text();
|
||||
|
||||
expect(content).toContain(formatDate("2026-02-01"));
|
||||
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
|
||||
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates printable report when PDF format is selected", async () => {
|
||||
const onClose = vi.fn();
|
||||
const mockWrite = vi.fn();
|
||||
@@ -83,16 +131,35 @@ describe("ReportModal", () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
firstDoseAt: null,
|
||||
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
refills: [
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({
|
||||
medicationStartDate: "2026-03-01",
|
||||
blisters: [{ usage: 1, every: 1, start: "2026-03-02T08:30:00.000Z" }],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -101,6 +168,11 @@ describe("ReportModal", () => {
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [html] = mockWrite.mock.calls.at(-1) ?? [];
|
||||
expect(html).toContain(formatDate("2026-03-01"));
|
||||
expect(html).toContain(formatDateTime("2026-03-02T08:30:00.000Z"));
|
||||
expect(html).toContain(formatDate("2026-03-03T12:00:00.000Z"));
|
||||
expect(html).toContain(formatDate("2026-03-04"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ function createSharedDataWithEmbeddedOverview() {
|
||||
imageUrl: null,
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
totalPills: null,
|
||||
@@ -49,14 +51,91 @@ function createSharedDataWithEmbeddedOverview() {
|
||||
prescriptionEnabled: false,
|
||||
prescriptionRemainingRefills: null,
|
||||
},
|
||||
{
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
imageUrl: null,
|
||||
packageType: "bottle",
|
||||
packCount: 0,
|
||||
packageAmountValue: null,
|
||||
packageAmountUnit: null,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 100,
|
||||
looseTablets: 100,
|
||||
currentStock: 40,
|
||||
capacity: 100,
|
||||
daysLeft: 40,
|
||||
nextIntakeDate: null,
|
||||
depletionDate: "2026-02-21",
|
||||
priority: "normal",
|
||||
expiryDate: null,
|
||||
medicationStartDate: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionRemainingRefills: null,
|
||||
},
|
||||
{
|
||||
name: "Hydrogel",
|
||||
genericName: null,
|
||||
imageUrl: null,
|
||||
packageType: "tube",
|
||||
packCount: 2,
|
||||
packageAmountValue: 40,
|
||||
packageAmountUnit: "g",
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 80,
|
||||
looseTablets: 80,
|
||||
currentStock: 80,
|
||||
capacity: 80,
|
||||
daysLeft: null,
|
||||
nextIntakeDate: null,
|
||||
depletionDate: null,
|
||||
priority: "normal",
|
||||
expiryDate: null,
|
||||
medicationStartDate: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionRemainingRefills: null,
|
||||
},
|
||||
{
|
||||
name: "Cough Syrup",
|
||||
genericName: null,
|
||||
imageUrl: null,
|
||||
packageType: "liquid_container",
|
||||
packCount: 3,
|
||||
packageAmountValue: 150,
|
||||
packageAmountUnit: "ml",
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 450,
|
||||
looseTablets: 450,
|
||||
currentStock: 450,
|
||||
capacity: 450,
|
||||
daysLeft: null,
|
||||
nextIntakeDate: null,
|
||||
depletionDate: null,
|
||||
priority: "normal",
|
||||
expiryDate: null,
|
||||
medicationStartDate: null,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionRemainingRefills: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createSharedDataWithTodayDose() {
|
||||
const now = new Date();
|
||||
now.setHours(10, 0, 0, 0);
|
||||
const dateOnlyMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
function createSharedDataWithTodayDose(referenceNow: Date) {
|
||||
const currentDay = new Date(referenceNow);
|
||||
currentDay.setHours(12, 0, 0, 0);
|
||||
const scheduledAt = new Date(currentDay);
|
||||
scheduledAt.setHours(9, 0, 0, 0);
|
||||
const dateOnlyMs = new Date(scheduledAt.getFullYear(), scheduledAt.getMonth(), scheduledAt.getDate()).getTime();
|
||||
const start = `${scheduledAt.getFullYear()}-${String(scheduledAt.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
scheduledAt.getDate()
|
||||
).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}T${String(scheduledAt.getHours()).padStart(2, "0")}:${String(scheduledAt.getMinutes()).padStart(2, "0")}:00`;
|
||||
|
||||
return {
|
||||
sharedBy: "Owner",
|
||||
@@ -79,8 +158,8 @@ function createSharedDataWithTodayDose() {
|
||||
expiryDate: null,
|
||||
notes: null,
|
||||
intakeRemindersEnabled: false,
|
||||
blisters: [{ usage: 1, every: 1, start: now.toISOString() }],
|
||||
intakes: [{ usage: 1, every: 1, start: now.toISOString(), takenBy: null, intakeRemindersEnabled: false }],
|
||||
blisters: [{ usage: 1, every: 1, start }],
|
||||
intakes: [{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }],
|
||||
updatedAt: null,
|
||||
dismissedUntil: null,
|
||||
lastStockCorrectionAt: null,
|
||||
@@ -185,7 +264,10 @@ describe("SharedSchedule", () => {
|
||||
});
|
||||
|
||||
it("shows the robot marker for automatically taken shared doses", async () => {
|
||||
const sharedData = createSharedDataWithTodayDose();
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
@@ -231,6 +313,9 @@ describe("SharedSchedule", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText("sharedOverview.columns.priority")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("100").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("2 x 40 form.packageAmountUnitG").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,6 +253,67 @@ describe("MedicationsPage", () => {
|
||||
expect(scheduleTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("shows weekday controls and validation error in the desktop schedule form", () => {
|
||||
mockFormHookValue = createMockFormHook({
|
||||
formChanged: true,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: [],
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
|
||||
expect(screen.getByText("form.blisters.weekdaysRequired")).toBeInTheDocument();
|
||||
expect(screen.getByText("form.blisters.weekdays")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("form.blisters.everyDays")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('button[type="submit"]')).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("toggles weekday selections in the desktop schedule form", () => {
|
||||
const setIntakeValue = vi.fn();
|
||||
mockFormHookValue = createMockFormHook({
|
||||
setIntakeValue,
|
||||
form: {
|
||||
...createMockFormHook().form,
|
||||
name: "Weekday Med",
|
||||
intakes: [
|
||||
{
|
||||
usage: "1",
|
||||
every: "1",
|
||||
startDate: "2024-01-01",
|
||||
startTime: "09:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed"] as const,
|
||||
takenBy: "",
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
fireEvent.click(screen.getByRole("tab", { name: "form.sections.schedule" }));
|
||||
fireEvent.click(screen.getByTitle("form.blisters.weekdaysLong.mon"));
|
||||
|
||||
expect(setIntakeValue).toHaveBeenCalledWith(0, "weekdays", ["mon", "wed"]);
|
||||
});
|
||||
|
||||
it("opens report modal from list actions", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByText("report.button"));
|
||||
@@ -431,4 +492,158 @@ describe("MedicationsPage form interactions", () => {
|
||||
expect(resetForm).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledWith({ modal: "edit" }, "");
|
||||
});
|
||||
|
||||
it("renders the shared medication enrichment section after generic name on desktop", () => {
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
|
||||
const genericNameLabel = screen.getByText("form.genericName");
|
||||
const enrichmentTitle = screen.getByText("form.enrichment.title");
|
||||
|
||||
expect(genericNameLabel.compareDocumentPosition(enrichmentTitle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByText("form.enrichment.collapsedHint")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "form.enrichment.toggleShow" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("searches and applies medication enrichment suggestions through the desktop form", async () => {
|
||||
const setForm = vi.fn();
|
||||
mockFormHookValue = createMockFormHook({ setForm });
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (url.startsWith("/api/medication-enrichment/search?")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: "Aspirin",
|
||||
normalizedQuery: "aspirin",
|
||||
hasMore: url.includes("limit=6"),
|
||||
results: [
|
||||
{
|
||||
code: "RX-ASPIRIN",
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "ingredient",
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: null,
|
||||
source: "rxnorm",
|
||||
},
|
||||
{
|
||||
code: "EMA-ASPIRIN",
|
||||
name: "Aspirin 500 mg tablets",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: "Bayer",
|
||||
therapeuticArea: "Pain",
|
||||
matchType: "brand",
|
||||
genericStatus: "original",
|
||||
authorisationDate: "2024-02-01",
|
||||
source: "ema",
|
||||
},
|
||||
...(url.includes("limit=12")
|
||||
? [
|
||||
{
|
||||
code: "NDC-ASPIRIN",
|
||||
name: "Bayer Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
authorisationHolder: null,
|
||||
therapeuticArea: null,
|
||||
matchType: "brand",
|
||||
genericStatus: "unknown",
|
||||
authorisationDate: null,
|
||||
source: "openfda",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/medication-enrichment/enrich") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
selection: {
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
therapeuticArea: "Pain",
|
||||
indication: "Pain relief",
|
||||
atcCode: "N02BA01",
|
||||
source: "rxnorm",
|
||||
},
|
||||
suggestions: {
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
medicationForm: "tablet",
|
||||
strengthOptions: [{ label: "500 mg", pillWeightMg: 500, doseUnit: "mg" }],
|
||||
},
|
||||
meta: {
|
||||
rxNormMatched: true,
|
||||
openFdaMatched: false,
|
||||
partial: false,
|
||||
note: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
});
|
||||
|
||||
renderPage();
|
||||
openNewMedicationForm();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.toggleShow" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("form.enrichment.searchPlaceholder"), {
|
||||
target: { value: " Aspirin " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.searchAction" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=6", {
|
||||
credentials: "include",
|
||||
});
|
||||
});
|
||||
|
||||
await screen.findByText("Aspirin 500 mg tablets");
|
||||
fireEvent.click(screen.getByRole("button", { name: "form.enrichment.showMoreAction" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/search?q=Aspirin&limit=12", {
|
||||
credentials: "include",
|
||||
});
|
||||
});
|
||||
|
||||
await screen.findByText("Bayer Aspirin");
|
||||
expect(screen.queryByRole("button", { name: "form.enrichment.showMoreAction" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "form.enrichment.applyAction" })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/medication-enrichment/enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: "Aspirin",
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
code: "RX-ASPIRIN",
|
||||
source: "rxnorm",
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
expect(setForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Aspirin",
|
||||
genericName: "Acetylsalicylic acid",
|
||||
medicationForm: "tablet",
|
||||
pillForm: "tablet",
|
||||
pillWeightMg: "500",
|
||||
doseUnit: "mg",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,4 +151,54 @@ describe("generateICS", () => {
|
||||
expect(() => generateICS(dailyMed)).not.toThrow();
|
||||
expect(() => generateICS(weeklyMed)).not.toThrow();
|
||||
});
|
||||
|
||||
it("exports weekday schedules with a weekly BYDAY rule", async () => {
|
||||
const med = createTestMed({
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateICS(med);
|
||||
|
||||
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
|
||||
const content = await blobArg.text();
|
||||
|
||||
expect(content).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
||||
expect(content).not.toContain("RRULE:FREQ=DAILY;INTERVAL=1");
|
||||
});
|
||||
|
||||
it("keeps interval schedules exported as daily interval rules", async () => {
|
||||
const med = createTestMed({
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 2,
|
||||
start: "2024-03-15T09:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateICS(med);
|
||||
|
||||
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
|
||||
const content = await blobArg.text();
|
||||
|
||||
expect(content).toContain("RRULE:FREQ=DAILY;INTERVAL=2");
|
||||
expect(content).not.toContain("BYDAY=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
|
||||
|
||||
describe("intake-units", () => {
|
||||
it("keeps ml unchanged and converts teaspoon and tablespoon usage to ml", () => {
|
||||
expect(convertLiquidUsageToMl(12, "ml")).toBe(12);
|
||||
expect(convertLiquidUsageToMl(2, "tsp")).toBe(10);
|
||||
expect(convertLiquidUsageToMl(3, "tbsp")).toBe(45);
|
||||
});
|
||||
|
||||
it("returns the existing liquid usage labels for each intake unit", () => {
|
||||
const t = vi.fn((key: string) => key);
|
||||
|
||||
expect(getLiquidCountUnitLabel("ml", 2, t)).toBe("form.packageAmountUnitMl");
|
||||
expect(getLiquidCountUnitLabel("tsp", 2, t)).toBe("form.blisters.teaspoons");
|
||||
expect(getLiquidCountUnitLabel("tbsp", 3, t)).toBe("form.blisters.tablespoons");
|
||||
|
||||
expect(t).toHaveBeenNthCalledWith(1, "form.packageAmountUnitMl");
|
||||
expect(t).toHaveBeenNthCalledWith(2, "form.blisters.teaspoons", { count: 2 });
|
||||
expect(t).toHaveBeenNthCalledWith(3, "form.blisters.tablespoons", { count: 3 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Coverage, Medication, StockThresholds } from "../../types";
|
||||
import {
|
||||
buildClearMissedPayload,
|
||||
buildSchedulePreview,
|
||||
calculateCoverage,
|
||||
computeMissedPastDoseIds,
|
||||
@@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => {
|
||||
expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id));
|
||||
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when));
|
||||
});
|
||||
|
||||
it("falls back legacy blisters to schedule events with a null intake unit", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy Liquid",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 120,
|
||||
looseTablets: 120,
|
||||
takenBy: [],
|
||||
packageType: "liquid_container",
|
||||
medicationForm: "liquid",
|
||||
blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildSchedulePreview(meds, "en", false);
|
||||
|
||||
expect(result.totalBlisters).toBe(1);
|
||||
expect(result.events[0]).toMatchObject({
|
||||
usage: 2,
|
||||
intakeUnit: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateCoverage", () => {
|
||||
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
|
||||
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
|
||||
});
|
||||
|
||||
it("converts liquid intake units to ml for automatic coverage calculations", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Liquid Med",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 1,
|
||||
totalPills: 120,
|
||||
looseTablets: 120,
|
||||
takenBy: [],
|
||||
packageType: "liquid_container",
|
||||
medicationForm: "liquid",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 2,
|
||||
every: 1,
|
||||
start: "2024-03-14T09:00:00",
|
||||
intakeUnit: "tbsp",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
expect(result.all[0].medsLeft).toBe(60);
|
||||
expect(result.all[0].daysLeft).toBe(2);
|
||||
});
|
||||
|
||||
it("per-intake takenBy counts person correctly in automatic mode", () => {
|
||||
// When intakes have per-intake takenBy, each person-intake pair is counted
|
||||
const meds: Medication[] = [
|
||||
@@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClearMissedPayload", () => {
|
||||
it("collects unique missed medication ids and the latest missed day", () => {
|
||||
const march10 = new Date("2024-03-10T09:00:00Z");
|
||||
const march11 = new Date("2024-03-11T09:00:00Z");
|
||||
const aspirinDoseMarch10 = "1-0-1710061200000";
|
||||
const aspirinDoseMarch11 = "1-0-1710147600000";
|
||||
const vitaminDDoseMarch11 = "2-0-1710147600000";
|
||||
const calciumDoseMarch11 = "3-0-1710147600000";
|
||||
|
||||
const pastDays = [
|
||||
{
|
||||
date: march10,
|
||||
meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }],
|
||||
},
|
||||
{
|
||||
date: march11,
|
||||
meds: [
|
||||
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] },
|
||||
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] },
|
||||
{ medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const medications = [
|
||||
{ id: 1, name: "Aspirin", dismissedUntil: null },
|
||||
{ id: 2, name: "Vitamin D", dismissedUntil: null },
|
||||
{ id: 3, name: "Calcium", dismissedUntil: "2024-03-11" },
|
||||
];
|
||||
|
||||
const payload = buildClearMissedPayload(
|
||||
pastDays,
|
||||
medications,
|
||||
new Set<string>(),
|
||||
new Set<string>([`${aspirinDoseMarch11}-John`])
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
medicationIds: [1, 2],
|
||||
until: "2024-03-11",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty payload when every remaining missed dose is already resolved", () => {
|
||||
const march10 = new Date("2024-03-10T09:00:00Z");
|
||||
const aspirinDoseMarch10 = "1-0-1710061200000";
|
||||
const vitaminDDoseMarch10 = "2-0-1710061200000";
|
||||
|
||||
const pastDays = [
|
||||
{
|
||||
date: march10,
|
||||
meds: [
|
||||
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["Alice"] }] },
|
||||
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch10, takenBy: [] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const medications = [
|
||||
{ id: 1, name: "Aspirin", dismissedUntil: null },
|
||||
{ id: 2, name: "Vitamin D", dismissedUntil: "2024-03-10" },
|
||||
];
|
||||
|
||||
const payload = buildClearMissedPayload(
|
||||
pastDays,
|
||||
medications,
|
||||
new Set<string>([`${aspirinDoseMarch10}-Alice`]),
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
medicationIds: [],
|
||||
until: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
|
||||
expect(past180.length).toBeGreaterThan(past90.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekday intake schedules", () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date("2024-03-18T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("builds preview events only on selected weekdays", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Weekday Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
packageType: "blister",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildSchedulePreview(meds, "en", false);
|
||||
const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr);
|
||||
|
||||
expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]);
|
||||
expect(result.totalBlisters).toBe(1);
|
||||
});
|
||||
|
||||
it("uses weekday schedules when calculating coverage", () => {
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Weekday Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
takenBy: [],
|
||||
packageType: "blister",
|
||||
blisters: [],
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-18T09:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["mon", "wed", "fri"],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
updatedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const preview = buildSchedulePreview(meds, "en", false);
|
||||
const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set());
|
||||
|
||||
expect(coverage.all[0]).toMatchObject({
|
||||
name: "Weekday Med",
|
||||
medsLeft: 9,
|
||||
daysLeft: 21,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,71 @@ import { isAmountBasedPackageType } from "./package-profiles";
|
||||
|
||||
// Common medication dose units
|
||||
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
|
||||
export type ScheduleMode = "interval" | "weekdays";
|
||||
export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
||||
|
||||
export type MedicationForm = "tablet" | "capsule" | "topical" | "liquid";
|
||||
export type PillForm = "tablet" | "capsule";
|
||||
export type LifecycleCategory = "refill_when_empty" | "treatment_period";
|
||||
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 }[] = [
|
||||
{ value: "mg", label: "mg" },
|
||||
@@ -49,6 +109,8 @@ export type Intake = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: ScheduleMode | null;
|
||||
weekdays?: WeekdayCode[] | null;
|
||||
intakeUnit?: IntakeUnit | null;
|
||||
takenBy: string | null; // Per-intake user assignment (single person or null)
|
||||
intakeRemindersEnabled: boolean;
|
||||
@@ -131,6 +193,8 @@ export type FormIntake = {
|
||||
every: string;
|
||||
startDate: string;
|
||||
startTime: string;
|
||||
scheduleMode?: ScheduleMode;
|
||||
weekdays?: WeekdayCode[];
|
||||
intakeUnit?: IntakeUnit;
|
||||
takenBy: string; // Single person or empty string (empty = null for everyone)
|
||||
intakeRemindersEnabled: boolean;
|
||||
@@ -271,6 +335,8 @@ export type SharedMedicationOverviewItem = {
|
||||
imageUrl: string | null;
|
||||
packageType: PackageType;
|
||||
packCount: number;
|
||||
packageAmountValue: number | null;
|
||||
packageAmountUnit: PackageAmountUnit | null;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
totalPills: number | null;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
import type { Medication } from "../types";
|
||||
import { getMedDisplayName } from "../types";
|
||||
import {
|
||||
getIntakeFrequencyText,
|
||||
getIntakeScheduleMode,
|
||||
getMedicationIntakes,
|
||||
getWeekdayIcsCode,
|
||||
normalizeWeekdays,
|
||||
} from "./intake-schedule";
|
||||
|
||||
/**
|
||||
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
|
||||
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
|
||||
*/
|
||||
export function generateICS(med: Medication): void {
|
||||
const displayName = getMedDisplayName(med);
|
||||
const events = med.blisters
|
||||
.map((blister, idx) => {
|
||||
const start = new Date(blister.start);
|
||||
const events = getMedicationIntakes(med)
|
||||
.map((intake, idx) => {
|
||||
const start = new Date(intake.start);
|
||||
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
|
||||
const interval = blister.every;
|
||||
const interval = intake.every;
|
||||
|
||||
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`;
|
||||
const summary = `💊 ${displayName} - ${pillInfo}`;
|
||||
const weekdayCodes = normalizeWeekdays(intake.weekdays);
|
||||
const frequencyText =
|
||||
getIntakeScheduleMode(intake) === "weekdays"
|
||||
? weekdayCodes.map(getWeekdayIcsCode).join(", ")
|
||||
: getIntakeFrequencyText(intake, (key, options) => {
|
||||
if (key === "common.daily") return "daily";
|
||||
if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`;
|
||||
return key;
|
||||
});
|
||||
const rrule =
|
||||
getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0
|
||||
? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}`
|
||||
: `RRULE:FREQ=DAILY;INTERVAL=${interval}`;
|
||||
const description = [
|
||||
`Medication: ${displayName}`,
|
||||
med.genericName ? `Generic: ${med.genericName}` : "",
|
||||
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
|
||||
`Dosage: ${pillInfo}`,
|
||||
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
|
||||
`Frequency: ${frequencyText}`,
|
||||
med.notes ? `Notes: ${med.notes}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
|
||||
DTSTAMP:${formatICSDate(new Date())}
|
||||
DTSTART:${formatICSDate(start)}
|
||||
DTEND:${formatICSDate(end)}
|
||||
RRULE:FREQ=DAILY;INTERVAL=${interval}
|
||||
${rrule}
|
||||
SUMMARY:${summary}
|
||||
DESCRIPTION:${description}
|
||||
BEGIN:VALARM
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Blister, Intake, ScheduleMode, WeekdayCode } from "../types";
|
||||
|
||||
type MedicationScheduleSource = {
|
||||
intakes?: Intake[] | null;
|
||||
blisters: Blister[];
|
||||
intakeRemindersEnabled?: boolean;
|
||||
};
|
||||
|
||||
type IntakeScheduleLike = {
|
||||
every?: number | string | null;
|
||||
scheduleMode?: ScheduleMode | null;
|
||||
weekdays?: ReadonlyArray<WeekdayCode> | null;
|
||||
};
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
|
||||
const WEEKDAY_LABELS: Record<WeekdayCode, { short: string; long: string; ics: string }> = {
|
||||
mon: { short: "form.blisters.weekdaysShort.mon", long: "form.blisters.weekdaysLong.mon", ics: "MO" },
|
||||
tue: { short: "form.blisters.weekdaysShort.tue", long: "form.blisters.weekdaysLong.tue", ics: "TU" },
|
||||
wed: { short: "form.blisters.weekdaysShort.wed", long: "form.blisters.weekdaysLong.wed", ics: "WE" },
|
||||
thu: { short: "form.blisters.weekdaysShort.thu", long: "form.blisters.weekdaysLong.thu", ics: "TH" },
|
||||
fri: { short: "form.blisters.weekdaysShort.fri", long: "form.blisters.weekdaysLong.fri", ics: "FR" },
|
||||
sat: { short: "form.blisters.weekdaysShort.sat", long: "form.blisters.weekdaysLong.sat", ics: "SA" },
|
||||
sun: { short: "form.blisters.weekdaysShort.sun", long: "form.blisters.weekdaysLong.sun", ics: "SU" },
|
||||
};
|
||||
|
||||
export function normalizeWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): WeekdayCode[] {
|
||||
if (!Array.isArray(weekdays) || weekdays.length === 0) return [];
|
||||
const normalizedSet = new Set<WeekdayCode>();
|
||||
for (const day of weekdays) {
|
||||
if (WEEKDAY_CODES.includes(day)) {
|
||||
normalizedSet.add(day);
|
||||
}
|
||||
}
|
||||
return WEEKDAY_CODES.filter((day) => normalizedSet.has(day));
|
||||
}
|
||||
|
||||
export function hasSelectedWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): boolean {
|
||||
return normalizeWeekdays(weekdays).length > 0;
|
||||
}
|
||||
|
||||
export function getIntakeScheduleMode(schedule: IntakeScheduleLike): ScheduleMode {
|
||||
return schedule.scheduleMode === "weekdays" ? "weekdays" : "interval";
|
||||
}
|
||||
|
||||
export function getNormalizedInterval(schedule: IntakeScheduleLike): number {
|
||||
const parsedEvery = Number(schedule.every);
|
||||
if (!Number.isFinite(parsedEvery) || parsedEvery <= 0) return 1;
|
||||
return Math.floor(parsedEvery);
|
||||
}
|
||||
|
||||
export function getWeekdayCode(date: Date): WeekdayCode {
|
||||
return WEEKDAY_CODES[(date.getDay() + 6) % 7];
|
||||
}
|
||||
|
||||
export function getWeekdayLabel(day: WeekdayCode, t: Translate, format: "short" | "long" = "short"): string {
|
||||
return t(WEEKDAY_LABELS[day][format]);
|
||||
}
|
||||
|
||||
export function getWeekdayIcsCode(day: WeekdayCode): string {
|
||||
return WEEKDAY_LABELS[day].ics;
|
||||
}
|
||||
|
||||
export function toggleWeekdaySelection(
|
||||
weekdays: ReadonlyArray<WeekdayCode> | null | undefined,
|
||||
day: WeekdayCode
|
||||
): WeekdayCode[] {
|
||||
const normalized = normalizeWeekdays(weekdays);
|
||||
if (normalized.includes(day)) {
|
||||
return normalized.filter((entry) => entry !== day);
|
||||
}
|
||||
return normalizeWeekdays([...normalized, day]);
|
||||
}
|
||||
|
||||
export function getMedicationIntakes(med: MedicationScheduleSource): Intake[] {
|
||||
if (med.intakes && med.intakes.length > 0) {
|
||||
return med.intakes;
|
||||
}
|
||||
return med.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
export function iterateIntakeOccurrences(
|
||||
intake: IntakeScheduleLike,
|
||||
start: Date,
|
||||
end: Date,
|
||||
callback: (occurrence: Date) => void
|
||||
): void {
|
||||
if (start > end) return;
|
||||
|
||||
if (getIntakeScheduleMode(intake) === "weekdays") {
|
||||
const weekdays = normalizeWeekdays(intake.weekdays);
|
||||
if (weekdays.length === 0) return;
|
||||
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
if (weekdays.includes(getWeekdayCode(cursor))) {
|
||||
callback(new Date(cursor));
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = getNormalizedInterval(intake);
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
callback(new Date(cursor));
|
||||
cursor.setDate(cursor.getDate() + interval);
|
||||
}
|
||||
}
|
||||
|
||||
export function getIntakeDailyRate(schedule: IntakeScheduleLike): number {
|
||||
if (getIntakeScheduleMode(schedule) === "weekdays") {
|
||||
return normalizeWeekdays(schedule.weekdays).length / 7;
|
||||
}
|
||||
return 1 / getNormalizedInterval(schedule);
|
||||
}
|
||||
|
||||
export function getIntakeFrequencyText(schedule: IntakeScheduleLike, t: Translate): string {
|
||||
if (getIntakeScheduleMode(schedule) === "weekdays") {
|
||||
return normalizeWeekdays(schedule.weekdays)
|
||||
.map((day) => getWeekdayLabel(day, t, "short"))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const every = getNormalizedInterval(schedule);
|
||||
return every === 1 ? t("common.daily") : t("common.everyNDays", { count: every });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IntakeUnit } from "../types";
|
||||
|
||||
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function convertLiquidUsageToMl(usage: number, unit: IntakeUnit | null | undefined): number {
|
||||
if (unit === "tsp") return usage * 5;
|
||||
if (unit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
}
|
||||
|
||||
export function getLiquidCountUnitLabel(unit: IntakeUnit | null | undefined, usage: number, t: Translate): string {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
}
|
||||
@@ -2,17 +2,10 @@
|
||||
// Schedule Building and Coverage Calculations
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
Blister,
|
||||
Coverage,
|
||||
Intake,
|
||||
Medication,
|
||||
PackageType,
|
||||
ScheduleEvent,
|
||||
StockStatus,
|
||||
StockThresholds,
|
||||
} from "../types";
|
||||
import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule";
|
||||
import { convertLiquidUsageToMl } from "./intake-units";
|
||||
|
||||
export function parseLocalDateTime(isoString: string): Date {
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
@@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
|
||||
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
|
||||
if (!isLiquidStock) return usage;
|
||||
|
||||
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get intakes for a medication, preferring new intakes format over legacy blisters
|
||||
*/
|
||||
function getIntakesForMed(med: Medication): Intake[] {
|
||||
// Use new intakes array if available and non-empty
|
||||
if (med.intakes && med.intakes.length > 0) {
|
||||
return med.intakes;
|
||||
}
|
||||
// Fallback to legacy blisters (convert to Intake format)
|
||||
return med.blisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // Legacy format has no per-intake takenBy
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blisters for a medication (for backward compatibility with coverage calculations)
|
||||
*/
|
||||
function getBlistersForMed(med: Medication): Blister[] {
|
||||
if (med.intakes && med.intakes.length > 0) {
|
||||
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
}
|
||||
return med.blisters;
|
||||
return convertLiquidUsageToMl(usage, intake.intakeUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
|
||||
end.setDate(end.getDate() + 180); // 6 months horizon
|
||||
|
||||
meds.forEach((med) => {
|
||||
const intakes = getIntakesForMed(med);
|
||||
const intakes = getMedicationIntakes(med);
|
||||
intakes.forEach((intake, idx) => {
|
||||
const start = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(start.getTime())) return;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
|
||||
iterateIntakeOccurrences(intake, start, end, (d) => {
|
||||
const isPast = d < todayStart;
|
||||
if (isPast && !includePast) continue;
|
||||
if (isPast && !includePast) return;
|
||||
const whenMs = d.getTime();
|
||||
// Use date-only timestamp for stable ID (immune to time changes)
|
||||
// This ensures changing intake times doesn't invalidate past dose tracking
|
||||
@@ -113,7 +75,7 @@ export function buildSchedulePreview(
|
||||
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
|
||||
intakeRemindersEnabled: intake.intakeRemindersEnabled,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
|
||||
events,
|
||||
today: todayCount,
|
||||
nextThree: events.length,
|
||||
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
|
||||
totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,10 +109,10 @@ export function calculateCoverage(
|
||||
): { low: Coverage[]; all: Coverage[] } {
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const now = Date.now();
|
||||
const nowDate = new Date(now);
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const intakes = getIntakesForMed(m);
|
||||
const blisters = getBlistersForMed(m);
|
||||
const intakes = getMedicationIntakes(m);
|
||||
// Count unique people from all intakes (for per-intake takenBy)
|
||||
const uniquePeople = new Set<string>();
|
||||
intakes.forEach((intake) => {
|
||||
@@ -165,11 +127,9 @@ export function calculateCoverage(
|
||||
// one person's dose — do NOT multiply by personCount again.
|
||||
// For legacy intakes (no takenBy), the intake applies to ALL people.
|
||||
let dailyRate = 0;
|
||||
blisters.forEach((_s, idx) => {
|
||||
const intake = intakes[idx];
|
||||
if (!intake) return;
|
||||
intakes.forEach((intake) => {
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||
const baseRate = usageForStock * getIntakeDailyRate(intake);
|
||||
if (intake?.takenBy) {
|
||||
// Per-intake takenBy: this intake is for exactly 1 person
|
||||
dailyRate += baseRate;
|
||||
@@ -189,29 +149,11 @@ export function calculateCoverage(
|
||||
// time (early intake), that dose is also counted as consumed immediately.
|
||||
// This prevents double-counting: once the scheduled time arrives, the dose
|
||||
// was already counted via the early-taken path, not again via time.
|
||||
blisters.forEach((s, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(s.start).getTime();
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
const intake = intakes[blisterIdx];
|
||||
if (!intake) return;
|
||||
intakes.forEach((intake, blisterIdx) => {
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
if (Number.isNaN(intakeStart.getTime())) return;
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
|
||||
// After a stock correction, start counting consumption from the NEXT
|
||||
// scheduled dose on this blister's grid, because the user's pill count
|
||||
// already reflects all consumption up to the correction time.
|
||||
// We align to the schedule grid so that e.g. correction at 15:40 with
|
||||
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
|
||||
// tomorrow's dose (24h later as the old code did).
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
if (Number.isNaN(effectiveStart)) return;
|
||||
|
||||
const intakePerson = intake?.takenBy;
|
||||
|
||||
// For per-intake takenBy, only count for that person
|
||||
@@ -223,18 +165,15 @@ export function calculateCoverage(
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||
|
||||
// Date-only timestamp of the last auto-consumed dose
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => {
|
||||
if (occurrence.getTime() <= stockCorrectionCutoff) return;
|
||||
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
occurrence.getFullYear(),
|
||||
occurrence.getMonth(),
|
||||
occurrence.getDate()
|
||||
).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
// Early intakes: count future doses already marked as taken.
|
||||
// The cutoff is the later of: last auto-consumed date or stock correction date.
|
||||
@@ -276,16 +215,15 @@ export function calculateCoverage(
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (medId === m.id && blisters[blisterIdx]) {
|
||||
const intake = intakes[blisterIdx];
|
||||
if (!intake) return;
|
||||
const intake = intakes[blisterIdx];
|
||||
if (medId === m.id && intake) {
|
||||
const usageForStock = normalizeIntakeUsageForStock(intake, m);
|
||||
// Convert blister start to date-only for comparison (dose timestamps are date-only)
|
||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStartDate.getFullYear(),
|
||||
blisterStartDate.getMonth(),
|
||||
blisterStartDate.getDate()
|
||||
const intakeStartDate = new Date(intake.start);
|
||||
const intakeStartDateOnly = new Date(
|
||||
intakeStartDate.getFullYear(),
|
||||
intakeStartDate.getMonth(),
|
||||
intakeStartDate.getDate()
|
||||
).getTime();
|
||||
|
||||
// Use actual takenAt timestamp for stock correction comparison.
|
||||
@@ -295,8 +233,8 @@ export function calculateCoverage(
|
||||
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (
|
||||
!Number.isNaN(blisterStartDateOnly) &&
|
||||
doseTimestamp >= blisterStartDateOnly &&
|
||||
!Number.isNaN(intakeStartDateOnly) &&
|
||||
doseTimestamp >= intakeStartDateOnly &&
|
||||
afterCorrectionOrNoCorrectionMs
|
||||
) {
|
||||
consumed += usageForStock;
|
||||
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
|
||||
);
|
||||
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
|
||||
}
|
||||
|
||||
export function buildClearMissedPayload(
|
||||
pastDays: ReadonlyArray<{
|
||||
date: Date;
|
||||
meds: ReadonlyArray<{
|
||||
medName: string;
|
||||
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
|
||||
}>;
|
||||
}>,
|
||||
medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>,
|
||||
takenDoses: Set<string>,
|
||||
dismissedDoses: Set<string>
|
||||
): { medicationIds: number[]; until: string | null } {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = medications.find((candidate) => getMedDisplayName(candidate as Medication) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandDoseIds([dose]).some((doseId) => !takenDoses.has(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+58
-121
@@ -6,9 +6,9 @@
|
||||
"": {
|
||||
"name": "medassist-ng",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^16.3.2"
|
||||
"lint-staged": "^16.4.0"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
@@ -76,9 +76,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.6.tgz",
|
||||
"integrity": "sha512-QnHe81PMslpy3mnpL8DnO2M4S4ZnYPkjlGCLWBZT/3R9M6b5daArWMMtEfP52/n174RKnwRIf3oT8+wc9ihSfQ==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz",
|
||||
"integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -92,20 +92,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.6",
|
||||
"@biomejs/cli-darwin-x64": "2.4.6",
|
||||
"@biomejs/cli-linux-arm64": "2.4.6",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.6",
|
||||
"@biomejs/cli-linux-x64": "2.4.6",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.6",
|
||||
"@biomejs/cli-win32-arm64": "2.4.6",
|
||||
"@biomejs/cli-win32-x64": "2.4.6"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.7",
|
||||
"@biomejs/cli-darwin-x64": "2.4.7",
|
||||
"@biomejs/cli-linux-arm64": "2.4.7",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.7",
|
||||
"@biomejs/cli-linux-x64": "2.4.7",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.7",
|
||||
"@biomejs/cli-win32-arm64": "2.4.7",
|
||||
"@biomejs/cli-win32-x64": "2.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.6.tgz",
|
||||
"integrity": "sha512-NW18GSyxr+8sJIqgoGwVp5Zqm4SALH4b4gftIA0n62PTuBs6G2tHlwNAOj0Vq0KKSs7Sf88VjjmHh0O36EnzrQ==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz",
|
||||
"integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -120,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.6.tgz",
|
||||
"integrity": "sha512-4uiE/9tuI7cnjtY9b07RgS7gGyYOAfIAGeVJWEfeCnAarOAS7qVmuRyX6d7JTKw28/mt+rUzMasYeZ+0R/U1Mw==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz",
|
||||
"integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -137,9 +137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.6.tgz",
|
||||
"integrity": "sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz",
|
||||
"integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -154,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.6.tgz",
|
||||
"integrity": "sha512-F/JdB7eN22txiTqHM5KhIVt0jVkzZwVYrdTR1O3Y4auBOQcXxHK4dxULf4z43QyZI5tsnQJrRBHZy7wwtL+B3A==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz",
|
||||
"integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -171,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.6.tgz",
|
||||
"integrity": "sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz",
|
||||
"integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -188,9 +188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.6.tgz",
|
||||
"integrity": "sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz",
|
||||
"integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -205,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.6.tgz",
|
||||
"integrity": "sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz",
|
||||
"integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -222,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.6.tgz",
|
||||
"integrity": "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg==",
|
||||
"version": "2.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz",
|
||||
"integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -280,19 +280,6 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
@@ -370,19 +357,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
@@ -428,28 +402,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.3.2.tgz",
|
||||
"integrity": "sha512-xKqhC2AeXLwiAHXguxBjuChoTTWFC6Pees0SHPwOpwlvI3BH7ZADFPddAdN3pgo3aiKgPUx/bxE78JfUnxQnlg==",
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
|
||||
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"listr2": "^9.0.5",
|
||||
"micromatch": "^4.0.8",
|
||||
"picomatch": "^4.0.3",
|
||||
"string-argv": "^0.3.2",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyexec": "^1.0.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -500,33 +464,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
@@ -556,6 +493,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -654,28 +604,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@
|
||||
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.6",
|
||||
"@biomejs/biome": "^2.4.7",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^16.3.2"
|
||||
"lint-staged": "^16.4.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"backend/src/**/*.ts": [
|
||||
|
||||
Reference in New Issue
Block a user