Compare commits

..

20 Commits

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

* fix: avoid double unescape in enrichment sanitization

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 20:39:38 +01:00
github-actions[bot] e1b47e82b2 chore: update test count badges [skip ci] 2026-03-20 14:04:44 +00:00
Daniel Volz 68ab79c713 feat: enable weekday-based medication scheduling
Closes #463
2026-03-20 14:58:25 +01:00
Daniel Volz 29f4c4e48d chore: release v1.20.2 (#462) 2026-03-17 05:55:29 +01:00
Daniel Volz 934519767a fix: restore obsolete actions in timeline views 2026-03-16 21:39:22 +01:00
Daniel Volz 9e224c0441 fix: improve shared schedule stock overview display 2026-03-16 21:33:55 +01:00
github-actions[bot] a0b0febe85 chore: update test count badges [skip ci] 2026-03-16 20:33:15 +00:00
Daniel Volz 5138d784cd chore: improve intake reminder observability 2026-03-16 21:28:53 +01:00
Daniel Volz 5b019f942d chore: clean up repo automation governance 2026-03-16 21:23:58 +01:00
Daniel Volz 14e783f111 fix: exclude obsolete medications from share flows 2026-03-16 21:21:41 +01:00
dependabot[bot] fb62227154 build(deps-dev): bump jsdom from 28.1.0 to 29.0.0 in /frontend
Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.0.0.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/v29.0.0/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v29.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:36:53 +01:00
dependabot[bot] 9b95be851c build(deps): bump dorny/paths-filter from 3 to 4
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-16 08:32:03 +01:00
Daniel Volz 0f9458b7cb chore: align vite 8 and plugin-react 6 stack
* chore: align vite 8 and plugin-react 6 stack

* fix: remove array index keys from intake rows

* chore: format shared schedule test fix
2026-03-16 08:26:50 +01:00
dependabot[bot] 01b59e66ca build(deps-dev): bump the minor-and-patch group with 2 updates
Squash merge Dependabot root minor-and-patch dependency updates from PR #438 after rebasing onto updated main.
2026-03-16 07:53:18 +01:00
dependabot[bot] 9180783c42 build(deps): bump the minor-and-patch group in /backend with 5 updates
Squash merge Dependabot backend minor-and-patch dependency updates from PR #443.
2026-03-16 07:51:28 +01:00
Daniel Volz cc636eb98b chore: release v1.20.1 (#437) 2026-03-15 20:01:58 +01:00
github-actions[bot] 8c77a87bc5 chore: update test count badges [skip ci] 2026-03-15 18:31:55 +00:00
Daniel Volz 908e4e724f fix: remove dead shareStockStatus gating from shared medication overview (#436)
The shareStockStatus UI toggle was replaced by shareMedicationOverview in
commit e0fb77d, but the backend gating logic was left intact. Users who
had previously set shareStockStatus=false were stuck with empty stock
values ('-') on the shared medication overview with no UI to change it.

- Remove showStockStatus parameter from buildSharedMedicationOverview()
- Remove visibility gating that nullified stock fields
- Remove shareStockStatus from settings API responses and PUT schema
- Remove shareStockStatus from frontend types, hooks, and context
- Clean up all related test fixtures and dead test cases
- DB column share_stock_status retained (never remove columns)
2026-03-15 19:27:39 +01:00
72 changed files with 8204 additions and 3771 deletions
+20 -5
View File
@@ -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.
-4
View File
@@ -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:
+1 -1
View File
@@ -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: |
+1 -1
View File
@@ -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: |
+8 -2
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-618%2F618-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
+654 -394
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.20.0",
"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",
+11 -5
View File
@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import {
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getScheduleMatchWindowMs,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
@@ -363,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>();
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
});
validDatesByIntake.set(idx, validDates);
}
@@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
const intake = intakes[intakeIdx];
if (!intake) continue;
const halfInterval = (intake.every * MS_PER_DAY) / 2;
const halfInterval = getScheduleMatchWindowMs(intake);
let bestMatch: number | null = null;
let bestDist = Infinity;
+12
View File
@@ -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),
+3 -3
View File
@@ -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
View File
@@ -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}`
);
}
+27 -19
View File
@@ -16,14 +16,14 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.3";
const EXPORT_VERSION = "1.4";
// =============================================================================
// Zod Schemas for Import Validation
@@ -33,6 +33,8 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
@@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
scheduleMode: "interval" | "weekdays";
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
@@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: intake.usage,
every: intake.every,
start: intake.start,
intakeUnit: null,
scheduleMode: intake.scheduleMode ?? "interval",
weekdays: intake.weekdays ?? [],
intakeUnit: intake.intakeUnit ?? null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules to both legacy and new formats
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy)
const intakesJson = JSON.stringify(
med.schedules.map((s) => ({
usage: s.usage,
every: s.every,
start: s.start,
intakeUnit: s.intakeUnit ?? null,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
const intakesJson = JSON.stringify(normalizedSchedules);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await db
.insert(medications)
+223
View File
@@ -0,0 +1,223 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { z } from "zod";
import { requireAuth } from "../plugins/auth.js";
import {
enrichMedicationSelection,
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
type MedicationEnrichmentEnrichRequest,
MedicationEnrichmentServiceError,
searchMedicationEnrichment,
} from "../services/medication-enrichment.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const searchQuerySchema = z.object({
q: z.string().trim().min(1).max(120),
limit: z.coerce
.number()
.int()
.min(1)
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
});
const enrichBodySchema = z.object({
query: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(140),
genericName: z.string().trim().max(140).nullable().optional(),
code: z.string().trim().min(1).max(160).nullable().optional(),
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
});
const searchQueryOpenApiSchema = {
type: "object",
required: ["q"],
properties: {
q: { type: "string", minLength: 1, maxLength: 120 },
limit: {
anyOf: [
{ type: "string", pattern: "^[0-9]+$" },
{
type: "integer",
minimum: 1,
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
},
],
},
},
} as const;
const enrichBodyOpenApiSchema = {
type: "object",
required: ["query", "name"],
properties: {
query: { type: "string", minLength: 1, maxLength: 120 },
name: { type: "string", minLength: 1, maxLength: 140 },
genericName: { type: "string", nullable: true, maxLength: 140 },
code: { type: "string", nullable: true, maxLength: 160 },
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
},
} as const;
const strengthOptionSchema = {
type: "object",
properties: {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
},
},
} as const;
const searchResponseSchema = {
type: "object",
properties: {
query: { type: "string" },
normalizedQuery: { type: "string" },
hasMore: { type: "boolean" },
results: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
genericName: { type: "string", nullable: true },
authorisationHolder: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
matchType: { type: "string", enum: ["brand", "ingredient"] },
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
authorisationDate: { type: "string", nullable: true },
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
},
},
},
},
} as const;
const enrichResponseSchema = {
type: "object",
properties: {
selection: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
indication: { type: "string", nullable: true },
atcCode: { type: "string", nullable: true },
source: {
type: "string",
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
},
},
},
suggestions: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
medicationForm: {
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
},
strengthOptions: { type: "array", items: strengthOptionSchema },
},
},
meta: {
type: "object",
properties: {
rxNormMatched: { type: "boolean" },
openFdaMatched: { type: "boolean" },
partial: { type: "boolean" },
note: { type: "string", nullable: true },
},
},
},
} as const;
function sendServiceError(error: unknown, reply: FastifyReply) {
if (error instanceof MedicationEnrichmentServiceError) {
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
}
return reply.status(503).send({
error: "Medication enrichment request failed.",
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
});
}
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
app.get(
"/medication-enrichment/search",
{
schema: {
querystring: searchQueryOpenApiSchema,
response: {
200: searchResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = searchQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Search request failed"
);
return sendServiceError(error, reply);
}
}
);
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
"/medication-enrichment/enrich",
{
schema: {
body: enrichBodyOpenApiSchema,
response: {
200: enrichResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = enrichBodySchema.safeParse(request.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await enrichMedicationSelection(parsed.data, request.log);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Enrich request failed"
);
return sendServiceError(error, reply);
}
}
);
}
+84 -104
View File
@@ -29,7 +29,13 @@ import {
PACKAGE_TYPES,
} 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));
}
+1 -1
View File
@@ -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);
+49 -28
View File
@@ -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"
);
+5 -21
View File
@@ -32,7 +32,6 @@ export type UserSettings = {
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -72,7 +71,6 @@ type SettingsBody = {
maxNaggingReminders: number;
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -99,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;
@@ -222,7 +213,6 @@ function getDefaultSettings() {
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
@@ -285,7 +275,6 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -330,7 +319,6 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -415,7 +403,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
@@ -482,7 +469,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: { type: "number" },
language: { type: "string", enum: ["en", "de"] },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
shareMedicationOverview: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
@@ -510,7 +496,6 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
@@ -561,7 +546,6 @@ export async function settingsRoutes(app: FastifyInstance) {
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
shareMedicationOverview: body.shareMedicationOverview ?? false,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
@@ -677,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),
@@ -690,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" });
@@ -707,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,
@@ -730,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 -20
View File
@@ -62,7 +62,6 @@ const shareReadResponseSchema = {
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
shareStockStatus: { type: "boolean" },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
@@ -97,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> {
@@ -156,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",
@@ -166,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));
@@ -187,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) => {
@@ -251,7 +248,6 @@ export async function shareRoutes(app: FastifyInstance) {
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
showStockStatus: settings?.shareStockStatus ?? true,
})
: null;
@@ -270,7 +266,6 @@ export async function shareRoutes(app: FastifyInstance) {
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
@@ -304,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",
@@ -327,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(
@@ -344,7 +342,6 @@ export async function shareRoutes(app: FastifyInstance) {
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
showStockStatus: settings?.shareStockStatus ?? true,
});
return {
@@ -396,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(
@@ -425,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 {
@@ -447,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 {
@@ -494,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>();
+24 -32
View File
@@ -1,14 +1,14 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getAverageOccurrencesPerDay,
getNextScheduledOccurrenceTime,
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect;
@@ -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(
@@ -149,9 +143,8 @@ export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
showStockStatus?: boolean;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays, showStockStatus = true } = options;
const { medications: medicationRows, doses, thresholdDays } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
@@ -187,30 +180,29 @@ 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);
const visibleCurrentStock = showStockStatus ? currentStock : null;
const visibleCapacity = showStockStatus ? capacity : null;
const visibleDaysLeft = showStockStatus ? daysLeft : null;
const visibleDepletionDate = showStockStatus ? depletionDate : null;
const visiblePriority = showStockStatus ? priority : null;
return {
name: medication.name,
genericName: medication.genericName,
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,
looseTablets: medication.looseTablets,
currentStock: visibleCurrentStock,
capacity: visibleCapacity,
daysLeft: visibleDaysLeft,
currentStock,
capacity,
daysLeft,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate: visibleDepletionDate,
priority: visiblePriority,
depletionDate,
priority,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
+17 -24
View File
@@ -1,6 +1,9 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
countScheduledOccurrencesInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
@@ -10,7 +13,6 @@ import {
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
const MS_PER_DAY = 86_400_000;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getDoseTakenAtMs(dose: DoseRow): number {
@@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: {
const intakeStart = parseLocalDateTime(intake.start).getTime();
if (Number.isNaN(intakeStart)) return;
const period = Math.max(1, intake.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = intakeStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
: intakeStart;
if (effectiveStart === null) return;
let peopleForThisIntake: Array<string | null>;
if (intake.takenBy) {
@@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: {
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= nowMs) {
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
intake,
effectiveStart,
nowMs
);
consumed += occurrences * usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
for (const dose of relevantDoses) {
+125 -80
View File
@@ -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
+19 -25
View File
@@ -18,10 +18,13 @@ import {
import {
type Blister,
calculateDepletionInfo,
countScheduledOccurrencesInRange,
createDefaultReminderState,
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
@@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder(
const lowStock: LowStockItem[] = [];
const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) {
const packageType = normalizePackageType(row.packageType);
@@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder(
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
every: i.every,
start: i.start,
scheduleMode: i.scheduleMode,
weekdays: i.weekdays,
}));
const originalTotalPills = isAmountBasedPackageType(packageType)
@@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder(
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
: blisterStart;
if (effectiveStart === null) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
@@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder(
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
blister,
effectiveStart,
now
);
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
+20 -9
View File
@@ -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 () => {
@@ -587,7 +603,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data.expiredAt).toBeTypeOf("string");
});
it("should hide stock fields in overview when share_stock_status is disabled", async () => {
it("should always show stock fields in overview regardless of share_stock_status setting", async () => {
await createMedication(testClient, userId, "Ibuprofen", ["Daniel"]);
const token = "0123456789abcdef";
await createShareToken(testClient, userId, "Daniel", token);
@@ -604,11 +620,8 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const [medication] = response.json().medications;
expect(medication.currentStock).toBeNull();
expect(medication.capacity).toBeNull();
expect(medication.daysLeft).toBeNull();
expect(medication.depletionDate).toBeNull();
expect(medication.priority).toBeNull();
expect(medication.currentStock).toBeTypeOf("number");
expect(medication.capacity).toBeTypeOf("number");
});
});
@@ -2469,7 +2482,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -2513,7 +2525,6 @@ describe("E2E Tests with Real Routes", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
+158 -133
View File
@@ -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();
});
});
+26 -57
View File
@@ -942,17 +942,17 @@ describe("Integration Tests", () => {
// ---------------------------------------------------------------------------
describe("Planner usage calculation", () => {
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
const futureDailyStart = "2030-01-15T08:00:00.000Z";
const futureEveningStart = "2030-01-15T20:00:00.000Z";
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
it("should calculate correct usage for daily medication", async () => {
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
// Schedule: 1 pill daily starting tomorrow (future date)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
// This avoids daylight-saving-time edge cases in local test environments.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -972,8 +972,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr, // 10 days
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -988,15 +988,8 @@ describe("Integration Tests", () => {
it("should detect insufficient stock", async () => {
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
// Schedule: 1 pill daily starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill daily starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1016,8 +1009,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1029,15 +1022,8 @@ describe("Integration Tests", () => {
it("should calculate weekly medication usage correctly", async () => {
// Create medication: 10 pills total
// Schedule: 1 pill every 7 days starting tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
const planEndStr = planEnd.toISOString();
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1056,8 +1042,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: thirtyFiveDayPlanEnd,
},
});
@@ -1070,18 +1056,8 @@ describe("Integration Tests", () => {
it("should handle multiple intake schedules per medication", async () => {
// Create medication with morning and evening doses
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const morningStart = tomorrow.toISOString();
const eveningStart = new Date(tomorrow);
eveningStart.setHours(20, 0, 0, 0);
const eveningStartStr = eveningStart.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const morningStart = futureDailyStart;
const eveningStartStr = futureEveningStart;
await app.inject({
method: "POST",
@@ -1103,8 +1079,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: morningStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -1116,14 +1092,7 @@ describe("Integration Tests", () => {
it("should calculate correct blisters needed", async () => {
// 10 pills per blister, need 25 pills → need 3 blisters
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(8, 0, 0, 0);
const intakeStart = tomorrow.toISOString();
const planEnd = new Date(tomorrow);
planEnd.setDate(planEnd.getDate() + 10);
const planEndStr = planEnd.toISOString();
const intakeStart = futureDailyStart;
await app.inject({
method: "POST",
@@ -1142,8 +1111,8 @@ describe("Integration Tests", () => {
method: "POST",
url: "/medications/usage",
payload: {
startDate: intakeStart,
endDate: planEndStr,
startDate: plannerWindowStart,
endDate: tenDayPlanEnd,
},
});
@@ -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
View File
@@ -140,7 +140,6 @@ describe("Real route coverage: settings/export/report", () => {
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.language).toBe("en");
expect(body.shareStockStatus).toBe(true);
expect(body.upcomingTodayOnly).toBe(false);
expect(body.shareScheduleTodayOnly).toBe(false);
});
@@ -177,7 +176,6 @@ describe("Real route coverage: settings/export/report", () => {
maxNaggingReminders: 4,
language: "en",
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: true,
shareScheduleTodayOnly: true,
swapDashboardMainSections: true,
@@ -238,7 +236,6 @@ describe("Real route coverage: settings/export/report", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -453,7 +450,6 @@ describe("Real route coverage: settings/export/report", () => {
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
})
);
});
@@ -509,7 +505,6 @@ describe("Real route coverage: settings/export/report", () => {
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
stockCalculationMode: "manual",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
@@ -556,7 +551,6 @@ describe("Real route coverage: settings/export/report", () => {
emailPrescriptionReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
}),
])
);
+151 -2
View File
@@ -6,22 +6,30 @@ import {
calculateDailyUsage,
calculateDepletionInfo,
cleanOldIntakeReminders,
countScheduledOccurrencesInRange,
createDefaultIntakeReminderState,
createDefaultReminderState,
forEachScheduledOccurrenceInRange,
formatInTimezone,
getAverageOccurrencesPerDay,
getCurrentHourInTimezone,
getMaxScheduledGapDays,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
normalizeIntake,
parseBlisters,
parseIntakeReminderState,
parseIntakesJson,
parseReminderState,
parseTakenByJson,
personTakesMedication,
type Weekday,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
@@ -267,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => {
});
});
describe("Scheduler Utils - Intake Schedule Normalization", () => {
describe("normalizeIntake", () => {
it("keeps interval schedules backward-compatible by default", () => {
const intake = normalizeIntake({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
});
expect(intake).toMatchObject({
usage: 2,
every: 3,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
});
});
it("normalizes malformed weekday schedules to the start date weekday", () => {
const intake = normalizeIntake({
usage: 1,
every: 99,
start: "2025-01-06T08:00:00",
scheduleMode: "weekdays",
weekdays: ["bogus", null],
});
expect(intake.scheduleMode).toBe("weekdays");
expect(intake.every).toBe(1);
expect(intake.weekdays).toEqual(["mon"]);
});
});
describe("parseIntakesJson", () => {
it("falls back to legacy interval data when unified intakes are absent", () => {
const intakes = parseIntakesJson(
null,
{
usageJson: "[1,2]",
everyJson: "[1,3]",
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
},
true
);
expect(intakes).toEqual([
{
usage: 1,
every: 1,
start: "2025-01-01T08:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
{
usage: 2,
every: 3,
start: "2025-01-02T20:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: true,
},
]);
});
});
});
describe("Scheduler Utils - Daily Usage Calculation", () => {
describe("calculateDailyUsage", () => {
it("should calculate daily usage for single daily dose", () => {
@@ -306,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
});
});
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
it("calculates average usage and gap length for weekday schedules", () => {
const weekdaysSchedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
});
it("finds the next weekday occurrence after a given timestamp", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
};
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
});
it("iterates weekday occurrences in canonical order within a range", () => {
const schedule = {
every: 1,
start: "2025-01-06T09:00:00",
scheduleMode: "weekdays" as const,
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
};
const occurrences: number[] = [];
forEachScheduledOccurrenceInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime(),
(occurrenceMs) => {
occurrences.push(occurrenceMs);
}
);
expect(occurrences.sort((a, b) => a - b)).toEqual([
new Date(2025, 0, 6, 9, 0, 0).getTime(),
new Date(2025, 0, 8, 9, 0, 0).getTime(),
new Date(2025, 0, 10, 9, 0, 0).getTime(),
]);
expect(
countScheduledOccurrencesInRange(
schedule,
new Date(2025, 0, 6, 0, 0, 0).getTime(),
new Date(2025, 0, 12, 23, 59, 59).getTime()
)
).toEqual({
count: 3,
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
});
});
});
describe("Scheduler Utils - Depletion Calculation", () => {
describe("calculateDepletionInfo", () => {
it("should calculate days left correctly", () => {
@@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
expect(result[0].pillWeightMg).toBe(500);
});
it("should skip blisters with zero interval", () => {
it("should treat zero interval as a daily fallback", () => {
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
medName: "TestMed",
usage: 1,
takenBy: null,
});
});
it("should handle multiple blisters", () => {
@@ -224,7 +224,6 @@ describe("Settings and API key security contracts", () => {
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
+2 -61
View File
@@ -51,7 +51,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
};
}
@@ -77,7 +76,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
shareStockStatus: Boolean(s.share_stock_status ?? 1),
};
});
@@ -104,7 +102,6 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
}>("/settings", async (request, reply) => {
const userId = 1;
@@ -177,7 +174,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
],
});
} else {
@@ -228,7 +225,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
1,
userId,
],
});
@@ -550,62 +547,6 @@ describe("Settings API", () => {
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
describe("Share Stock Status", () => {
it("should default to true (show stock on shared links)", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(true);
});
it("should disable share stock status", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(false);
});
it("should re-enable share stock status", async () => {
// Disable first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
// Re-enable
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: true },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
-45
View File
@@ -10,7 +10,6 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
@@ -142,14 +141,6 @@ async function registerShareRoutes(ctx: TestContext) {
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
// Get shareStockStatus setting
const shareStockResult = await client.execute({
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
args: [share.user_id],
});
const shareStockStatus =
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
return {
takenBy: share.taken_by,
sharedBy: share.owner_username,
@@ -158,7 +149,6 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -431,41 +421,6 @@ describe("Share Link API", () => {
expect(med.blisters).toHaveLength(1);
expect(med.blisters[0].usage).toBe(1);
expect(med.blisters[0].every).toBe(1);
// shareStockStatus should default to true
expect(data.shareStockStatus).toBe(true);
});
it("should respect shareStockStatus setting when disabled", async () => {
// Create medication
await createTestMedication(ctx.client, {
userId,
name: "TestMed",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
});
// Set shareStockStatus to false
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
// Create share token
const token = await createTestShareToken(ctx.client, {
userId,
takenBy: "Daniel",
scheduleDays: 30,
});
const response = await ctx.app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(false);
});
it("should return 404 for invalid token", async () => {
+328 -88
View File
@@ -6,14 +6,34 @@
import { getDateLocale, type Language } from "../i18n/translations.js";
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
export type IntakeScheduleMode = "interval" | "weekdays";
type ScheduleLike = {
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
export type Blister = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
};
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
scheduleMode?: IntakeScheduleMode;
weekdays?: Weekday[];
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
@@ -22,6 +42,278 @@ export type Intake = {
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
value === "ml" || value === "tsp" || value === "tbsp";
const weekdayToJavascriptDay: Record<Weekday, number> = {
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
sun: 0,
};
function isWeekday(value: unknown): value is Weekday {
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
}
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
return value === "weekdays" ? "weekdays" : "interval";
}
function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
}
export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime();
}
export function getWeekdayFromDate(date: Date): Weekday {
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
return weekday ?? "mon";
}
export function getWeekdayFromStart(start: string): Weekday {
const startDate = parseLocalDateTime(start);
if (Number.isNaN(startDate.getTime())) {
return "mon";
}
return getWeekdayFromDate(startDate);
}
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
if (!Array.isArray(value)) {
return [getWeekdayFromStart(start)];
}
const uniqueWeekdays = new Set<Weekday>();
for (const weekday of value) {
if (isWeekday(weekday)) {
uniqueWeekdays.add(weekday);
}
}
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
}
function createOccurrenceAtDate(date: Date, startDate: Date): number {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds(),
startDate.getMilliseconds()
).getTime();
}
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
if (schedule.scheduleMode !== "weekdays") {
return [];
}
if (schedule.weekdays && schedule.weekdays.length > 0) {
return schedule.weekdays;
}
return [getWeekdayFromStart(schedule.start)];
}
export function getAverageOccurrencesPerDay(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode === "weekdays") {
return getNormalizedWeekdays(schedule).length / 7;
}
return 1 / Math.max(1, schedule.every);
}
export function getMaxScheduledGapDays(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
if (schedule.scheduleMode !== "weekdays") {
return Math.max(1, schedule.every);
}
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
if (weekdays.length === 0) {
return 7;
}
let maxGap = 0;
for (let index = 0; index < weekdays.length; index++) {
const current = weekdays[index];
const next = weekdays[(index + 1) % weekdays.length];
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
if (gap > maxGap) {
maxGap = gap;
}
}
return maxGap || 7;
}
export function getScheduleMatchWindowMs(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
): number {
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
}
export function getNextScheduledOccurrenceTime(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
fromMs: number,
inclusive: boolean = true
): number | null {
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime)) {
return null;
}
const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
if (startTime >= lowerBound) {
return startTime;
}
const intervals = Math.ceil((lowerBound - startTime) / period);
return startTime + intervals * period;
}
const candidateStart = Math.max(lowerBound, startTime);
const candidateDateOnly = toDateOnly(new Date(candidateStart));
let nextOccurrence: number | null = null;
for (const weekday of getNormalizedWeekdays(schedule)) {
const candidateDate = new Date(candidateDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
candidateDate.setDate(candidateDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
if (occurrenceMs < candidateStart) {
candidateDate.setDate(candidateDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
}
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
nextOccurrence = occurrenceMs;
}
}
return nextOccurrence;
}
export function forEachScheduledOccurrenceInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number,
callback: (occurrenceMs: number) => void
): void {
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
return;
}
const startDate = parseLocalDateTime(schedule.start);
const startTime = startDate.getTime();
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
return;
}
if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000;
let occurrenceMs = startTime;
if (occurrenceMs < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
occurrenceMs += intervals * period;
}
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs);
}
}
return;
}
const lowerBound = Math.max(rangeStartMs, startTime);
const firstDateOnly = toDateOnly(new Date(lowerBound));
for (const weekday of getNormalizedWeekdays(schedule)) {
const occurrenceDate = new Date(firstDateOnly);
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
if (occurrenceMs < lowerBound) {
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
while (occurrenceMs <= rangeEndMs) {
callback(occurrenceMs);
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
}
}
}
export function countScheduledOccurrencesInRange(
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
rangeStartMs: number,
rangeEndMs: number
): { count: number; lastOccurrenceMs: number | null } {
let count = 0;
let lastOccurrenceMs: number | null = null;
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
count += 1;
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
lastOccurrenceMs = occurrenceMs;
}
});
return { count, lastOccurrenceMs };
}
export function normalizeIntake(
value: {
usage?: unknown;
every?: unknown;
start?: unknown;
scheduleMode?: unknown;
weekdays?: unknown;
intakeUnit?: unknown;
takenBy?: unknown;
intakeRemindersEnabled?: unknown;
},
defaultIntakeRemindersEnabled: boolean = false
): Intake {
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
let every = 1;
if (scheduleMode !== "weekdays") {
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
every = value.every;
}
}
return {
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
every,
start,
scheduleMode,
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
intakeRemindersEnabled:
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
};
}
/**
* Normalize intake usage for stock math.
*
@@ -225,15 +517,7 @@ export function parseIntakesJson(
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: Record<string, unknown>) => ({
usage: typeof intake.usage === "number" ? intake.usage : 0,
every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
}
} catch {
// Fall through to legacy parsing
@@ -243,14 +527,18 @@ export function parseIntakesJson(
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
return blisters.map((b) =>
normalizeIntake(
{
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null,
},
medicationIntakeRemindersEnabled ?? false
)
);
}
return [];
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: string[
/** Calculate daily usage from blisters */
export function calculateDailyUsage(blisters: Blister[]): number {
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
return blisters.reduce((sum, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
}
/** Calculate depletion information for a medication */
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today
let currentTime = startTime;
// If start is in the past, calculate the first occurrence on or after todayStart
if (currentTime < todayStart.getTime()) {
const elapsed = todayStart.getTime() - startTime;
const intervals = Math.floor(elapsed / intervalMs);
currentTime = startTime + intervals * intervalMs;
}
// Collect all intakes for today
while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
currentTime += intervalMs;
}
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
const intakeDate = new Date(occurrenceMs);
result.push({
medName,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
});
}
return result;
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
}
/**
@@ -444,40 +713,11 @@ export function getUpcomingIntakes(
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const intake = intakes[blisterIdx];
const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
// Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime;
// If start is in the past, calculate occurrences
if (nextTime < now) {
const elapsed = now - startTime;
const intervals = Math.floor(elapsed / intervalMs);
// Check the current occurrence (today's scheduled time, even if past)
const currentOccurrence = startTime + intervals * intervalMs;
// And the next occurrence
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
// If today's occurrence notification time falls in current minute and intake hasn't happened
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
// but the intake time is still in the future — include it so the advance
// reminder can still be sent rather than falling into a dead zone.
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
}
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
if (nextTime === null) continue;
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
+17 -7
View File
@@ -114,8 +114,10 @@ test.describe("Share Schedule", () => {
const personSelect = modal.locator("select").first();
await expect(personSelect).toBeVisible();
// Should contain Alice and Bob options
await expect(personSelect.locator("option")).toHaveCount(2);
// Should contain Alice and Bob options.
// The dialog can also include an "all people" option, so assert presence instead of exact count.
await expect(personSelect.locator('option[value="Alice"]')).toBeAttached();
await expect(personSelect.locator('option[value="Bob"]')).toBeAttached();
// Close
await page.locator("button.modal-close").click();
@@ -187,7 +189,7 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
// The page should show Alice's medication name
const content = sharedSchedule.getByText(MED_ALICE);
const content = sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first();
try {
await expect(content).toBeVisible({ timeout: 10000 });
} catch {
@@ -236,12 +238,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_ALICE }).first()).toBeVisible({
timeout: 10000,
});
}
// Visit Bob's share — should show Bob's med
@@ -251,12 +257,16 @@ test.describe("Share Schedule", () => {
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
try {
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
await expect(sharedSchedule.locator(".med-name-text", { hasText: MED_BOB }).first()).toBeVisible({
timeout: 10000,
});
}
});
+784 -1572
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.20.0",
"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"
}
}
+12 -29
View File
@@ -20,11 +20,14 @@ import {
getMedDisplayName,
getMedTotal,
getPackageSize,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
@@ -254,32 +257,16 @@ export function MedDetailModal({
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
const getScheduleUsageLabel = (usage: number, intakeUnit?: IntakeUnit | null) => {
if (isLiquidContainerPackageType(selectedMed.packageType)) {
if (intakeUnit === "tsp") {
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
}
if (intakeUnit === "tbsp") {
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
}
return `${usage} ${t("form.packageAmountUnitMl")}`;
return `${usage} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
const scheduleIntakes =
selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: false,
intakeUnit: null,
}));
const scheduleIntakes = getMedicationIntakes(selectedMed);
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull);
@@ -969,7 +956,7 @@ export function MedDetailModal({
</div>
{/* Intake Schedule Section */}
{selectedMed.blisters.length > 0 && (
{scheduleIntakes.length > 0 && (
<div className="med-detail-section">
<h3>
{t("modal.intakeSchedule")}{" "}
@@ -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>
);
}
+214 -93
View File
@@ -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>
+30 -44
View File
@@ -10,6 +10,8 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -290,20 +292,6 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
type TFn = (key: string, opts?: Record<string, unknown>) => string;
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return "-";
return `${m[3]}.${m[2]}.${m[1]}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return `${fmtDate(iso)}`;
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
}
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
@@ -353,7 +341,7 @@ function generateTextReport(
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push("");
for (const med of meds) {
@@ -373,8 +361,8 @@ function generateTextReport(
lines.push(
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
);
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(med.obsoleteAt)));
lines.push("");
// Package / Stock
@@ -391,24 +379,23 @@ function generateTextReport(
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), formatDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push("");
// Intake Schedule
const allIntakes = med.intakes ?? med.blisters;
const allIntakes = getMedicationIntakes(med);
const intakes = personFilter
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allIntakes;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t);
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
@@ -420,7 +407,7 @@ function generateTextReport(
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
lines.push(item(t("report.docPrescriptionExpiry"), formatDate(med.prescriptionExpiryDate)));
lines.push("");
}
@@ -434,8 +421,8 @@ function generateTextReport(
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
}
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
} else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
}
@@ -445,7 +432,7 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -528,7 +515,7 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const intakes = getMedicationIntakes(med);
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
@@ -560,11 +547,11 @@ function buildPrintHtml(
);
if (med.medicationStartDate)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
);
if (med.isObsolete && med.obsoleteAt)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
);
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
@@ -591,7 +578,7 @@ function buildPrintHtml(
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
@@ -599,18 +586,17 @@ function buildPrintHtml(
// Intake Schedule
const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allPrintIntakes;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
@@ -623,7 +609,7 @@ function buildPrintHtml(
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`;
}
@@ -639,9 +625,9 @@ function buildPrintHtml(
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
@@ -652,7 +638,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
@@ -708,7 +694,7 @@ function buildPrintHtml(
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
${sections.join("\n")}
</body>
</html>`;
@@ -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">
+166 -95
View File
@@ -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>
+10 -24
View File
@@ -5,11 +5,13 @@
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, Medication, StockThresholds } from "../types";
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -40,19 +42,9 @@ export function UserFilterModal({
);
};
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
};
const formatIntakeUsageLabel = (
med: Medication,
usage: number,
intakeUnit?: "ml" | "tsp" | "tbsp" | null
): string => {
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
if (isLiquidMedication(med)) {
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
}
if (isTubePackageType(med.packageType)) {
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
@@ -111,14 +103,9 @@ export function UserFilterModal({
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
// Get intakes relevant to this person
const personIntakes = (
med.intakes ||
med.blisters.map((b) => ({
...b,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}))
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
);
return (
<div
@@ -146,7 +133,7 @@ export function UserFilterModal({
hour: "2-digit",
minute: "2-digit",
});
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.scheduleMode ?? "interval"}-${(intake.weekdays ?? []).join("")}-${intake.takenBy ?? ""}`;
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
return (
<span key={intakeKey} className="user-med-intake-item">
@@ -154,8 +141,7 @@ export function UserFilterModal({
{allowsPillFormSelection(med.packageType) &&
med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
{t("modal.at")} {timeStr}
{getIntakeFrequencyText(intake, t)} {t("modal.at")} {timeStr}
</span>
);
})}
+2
View File
@@ -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";
-1
View File
@@ -792,7 +792,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.shareMedicationOverview !== savedSettings.shareMedicationOverview ||
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
+9 -2
View File
@@ -9,6 +9,7 @@ import {
normalizePackageType,
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
export const defaultBlister = (): FormBlister => {
const now = new Date();
@@ -30,6 +31,8 @@ export const defaultIntake = (takenBy: string = ""): FormIntake => {
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
scheduleMode: "interval",
weekdays: [],
intakeUnit: "ml",
takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false,
@@ -93,7 +96,7 @@ export interface UseMedicationFormReturn {
addBlister: () => void;
removeBlister: (idx: number) => void;
// Intake management with per-intake takenBy
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
setIntakeValue: <K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => void;
addIntake: (takenBy?: string) => void;
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void;
@@ -189,7 +192,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
}, []);
// Intake management with per-intake takenBy
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
const setIntakeValue = useCallback(<K extends keyof FormIntake>(idx: number, field: K, value: FormIntake[K]) => {
setForm((prev) => {
const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value };
@@ -219,6 +222,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(i.every),
startDate: toDateValue(i.start),
startTime: toTimeValue(i.start),
scheduleMode: (i.scheduleMode === "weekdays" ? "weekdays" : "interval") as FormIntake["scheduleMode"],
weekdays: normalizeWeekdays(i.weekdays),
intakeUnit: (i.intakeUnit ?? "ml") as FormIntake["intakeUnit"],
takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled,
@@ -228,6 +233,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
scheduleMode: "interval" as const,
weekdays: [],
intakeUnit: "ml" as const,
takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
-3
View File
@@ -46,7 +46,6 @@ export interface Settings {
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
@@ -98,7 +97,6 @@ const defaultSettings: Settings = {
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
@@ -258,7 +256,6 @@ export function useSettings(): UseSettingsReturn {
shoutrrrIntakeReminders: settingsToSave.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
shareMedicationOverview: settingsToSave.shareMedicationOverview,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
+68 -1
View File
@@ -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)",
+68 -1
View File
@@ -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)",
+113 -85
View File
@@ -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">
+534 -102
View File
@@ -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}
+85 -68
View File
@@ -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
View File
@@ -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);
+31 -31
View File
@@ -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);
});
@@ -18,7 +18,6 @@ function createSharedData() {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
medications: [],
};
}
@@ -35,6 +34,8 @@ function createSharedDataWithEmbeddedOverview() {
imageUrl: null,
packageType: "blister",
packCount: 1,
packageAmountValue: null,
packageAmountUnit: null,
blistersPerPack: 2,
pillsPerBlister: 10,
totalPills: null,
@@ -50,21 +51,97 @@ 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",
takenBy: "Max",
scheduleDays: 30,
automaticDoseId: `1-0-${dateOnlyMs}`,
shareStockStatus: true,
medications: [
{
id: 1,
@@ -81,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,
@@ -187,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")) {
@@ -233,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();
});
});
@@ -22,7 +22,6 @@ function createSharedData(overrides: Record<string, unknown> = {}) {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: true,
stockCalculationMode: "automatic",
@@ -131,7 +131,6 @@ describe("useAppContext", () => {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
expiryWarningDays: 30,
},
@@ -171,7 +170,6 @@ describe("useAppContext", () => {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
shareMedicationOverview: false,
expiryWarningDays: 30,
},
@@ -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();
});
});
@@ -43,7 +43,6 @@ const createMockContext = (overrides = {}) => ({
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
+50
View File
@@ -151,4 +151,54 @@ describe("generateICS", () => {
expect(() => generateICS(dailyMed)).not.toThrow();
expect(() => generateICS(weeklyMed)).not.toThrow();
});
it("exports weekday schedules with a weekly BYDAY rule", async () => {
const med = createTestMed({
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
});
generateICS(med);
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
const content = await blobArg.text();
expect(content).toContain("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR");
expect(content).not.toContain("RRULE:FREQ=DAILY;INTERVAL=1");
});
it("keeps interval schedules exported as daily interval rules", async () => {
const med = createTestMed({
intakes: [
{
usage: 1,
every: 2,
start: "2024-03-15T09:00:00",
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
});
generateICS(med);
const blobArg = mockCreateObjectURL.mock.calls[0][0] as Blob;
const content = await blobArg.text();
expect(content).toContain("RRULE:FREQ=DAILY;INTERVAL=2");
expect(content).not.toContain("BYDAY=");
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
describe("intake-units", () => {
it("keeps ml unchanged and converts teaspoon and tablespoon usage to ml", () => {
expect(convertLiquidUsageToMl(12, "ml")).toBe(12);
expect(convertLiquidUsageToMl(2, "tsp")).toBe(10);
expect(convertLiquidUsageToMl(3, "tbsp")).toBe(45);
});
it("returns the existing liquid usage labels for each intake unit", () => {
const t = vi.fn((key: string) => key);
expect(getLiquidCountUnitLabel("ml", 2, t)).toBe("form.packageAmountUnitMl");
expect(getLiquidCountUnitLabel("tsp", 2, t)).toBe("form.blisters.teaspoons");
expect(getLiquidCountUnitLabel("tbsp", 3, t)).toBe("form.blisters.tablespoons");
expect(t).toHaveBeenNthCalledWith(1, "form.packageAmountUnitMl");
expect(t).toHaveBeenNthCalledWith(2, "form.blisters.teaspoons", { count: 2 });
expect(t).toHaveBeenNthCalledWith(3, "form.blisters.tablespoons", { count: 3 });
});
});
+223
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Coverage, Medication, StockThresholds } from "../../types";
import {
buildClearMissedPayload,
buildSchedulePreview,
calculateCoverage,
computeMissedPastDoseIds,
@@ -278,6 +279,33 @@ describe("buildSchedulePreview", () => {
expect(zResult.events.map((event) => event.id)).toEqual(localResult.events.map((event) => event.id));
expect(zResult.events.map((event) => event.when)).toEqual(localResult.events.map((event) => event.when));
});
it("falls back legacy blisters to schedule events with a null intake unit", () => {
const meds: Medication[] = [
{
id: 1,
name: "Legacy Liquid",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [{ usage: 2, every: 1, start: "2024-03-15T09:00:00" }],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
expect(result.totalBlisters).toBe(1);
expect(result.events[0]).toMatchObject({
usage: 2,
intakeUnit: null,
});
});
});
describe("calculateCoverage", () => {
@@ -376,6 +404,41 @@ describe("calculateCoverage", () => {
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
});
it("converts liquid intake units to ml for automatic coverage calculations", () => {
const meds: Medication[] = [
{
id: 1,
name: "Liquid Med",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 120,
looseTablets: 120,
takenBy: [],
packageType: "liquid_container",
medicationForm: "liquid",
blisters: [],
intakes: [
{
usage: 2,
every: 1,
start: "2024-03-14T09:00:00",
intakeUnit: "tbsp",
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
expect(result.all[0].medsLeft).toBe(60);
expect(result.all[0].daysLeft).toBe(2);
});
it("per-intake takenBy counts person correctly in automatic mode", () => {
// When intakes have per-intake takenBy, each person-intake pair is counted
const meds: Medication[] = [
@@ -1987,6 +2050,83 @@ describe("dose tracking survives medication edits (regression)", () => {
});
});
describe("buildClearMissedPayload", () => {
it("collects unique missed medication ids and the latest missed day", () => {
const march10 = new Date("2024-03-10T09:00:00Z");
const march11 = new Date("2024-03-11T09:00:00Z");
const aspirinDoseMarch10 = "1-0-1710061200000";
const aspirinDoseMarch11 = "1-0-1710147600000";
const vitaminDDoseMarch11 = "2-0-1710147600000";
const calciumDoseMarch11 = "3-0-1710147600000";
const pastDays = [
{
date: march10,
meds: [{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["John"] }] }],
},
{
date: march11,
meds: [
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch11, takenBy: ["John"] }] },
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch11, takenBy: [] }] },
{ medName: "Calcium", doses: [{ id: calciumDoseMarch11, takenBy: [] }] },
],
},
];
const medications = [
{ id: 1, name: "Aspirin", dismissedUntil: null },
{ id: 2, name: "Vitamin D", dismissedUntil: null },
{ id: 3, name: "Calcium", dismissedUntil: "2024-03-11" },
];
const payload = buildClearMissedPayload(
pastDays,
medications,
new Set<string>(),
new Set<string>([`${aspirinDoseMarch11}-John`])
);
expect(payload).toEqual({
medicationIds: [1, 2],
until: "2024-03-11",
});
});
it("returns an empty payload when every remaining missed dose is already resolved", () => {
const march10 = new Date("2024-03-10T09:00:00Z");
const aspirinDoseMarch10 = "1-0-1710061200000";
const vitaminDDoseMarch10 = "2-0-1710061200000";
const pastDays = [
{
date: march10,
meds: [
{ medName: "Aspirin", doses: [{ id: aspirinDoseMarch10, takenBy: ["Alice"] }] },
{ medName: "Vitamin D", doses: [{ id: vitaminDDoseMarch10, takenBy: [] }] },
],
},
];
const medications = [
{ id: 1, name: "Aspirin", dismissedUntil: null },
{ id: 2, name: "Vitamin D", dismissedUntil: "2024-03-10" },
];
const payload = buildClearMissedPayload(
pastDays,
medications,
new Set<string>([`${aspirinDoseMarch10}-Alice`]),
new Set<string>()
);
expect(payload).toEqual({
medicationIds: [],
until: null,
});
});
});
// =============================================================================
// Test Helpers
// =============================================================================
@@ -2322,3 +2462,86 @@ describe("past schedule windowing", () => {
expect(past180.length).toBeGreaterThan(past90.length);
});
});
describe("weekday intake schedules", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2024-03-18T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("builds preview events only on selected weekdays", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = buildSchedulePreview(meds, "en", false);
const weekdayDateStrings = result.events.slice(0, 3).map((event) => event.dateStr);
expect(weekdayDateStrings).toEqual(["Mon, Mar 18", "Wed, Mar 20", "Fri, Mar 22"]);
expect(result.totalBlisters).toBe(1);
});
it("uses weekday schedules when calculating coverage", () => {
const meds: Medication[] = [
{
id: 1,
name: "Weekday Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
takenBy: [],
packageType: "blister",
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-18T09:00:00",
scheduleMode: "weekdays",
weekdays: ["mon", "wed", "fri"],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const preview = buildSchedulePreview(meds, "en", false);
const coverage = calculateCoverage(meds, preview.events, "en", 7, "automatic", new Set());
expect(coverage.all[0]).toMatchObject({
name: "Weekday Med",
medsLeft: 9,
daysLeft: 21,
});
});
});
+66 -1
View File
@@ -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;
@@ -253,7 +317,6 @@ export type SharedScheduleData = {
expiryWarningDays?: number;
};
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
shareMedicationOverview?: boolean;
medicationOverview?: SharedMedicationOverviewItem[] | null;
upcomingTodayOnly?: boolean;
@@ -272,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;
+27 -7
View File
@@ -4,6 +4,13 @@
import type { Medication } from "../types";
import { getMedDisplayName } from "../types";
import {
getIntakeFrequencyText,
getIntakeScheduleMode,
getMedicationIntakes,
getWeekdayIcsCode,
normalizeWeekdays,
} from "./intake-schedule";
/**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
@@ -20,20 +27,33 @@ function formatICSDate(date: Date): string {
*/
export function generateICS(med: Medication): void {
const displayName = getMedDisplayName(med);
const events = med.blisters
.map((blister, idx) => {
const start = new Date(blister.start);
const events = getMedicationIntakes(med)
.map((intake, idx) => {
const start = new Date(intake.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
const interval = blister.every;
const interval = intake.every;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
const pillInfo = `${intake.usage} pill${intake.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${intake.usage * med.pillWeightMg} mg)` : ""}`;
const summary = `💊 ${displayName} - ${pillInfo}`;
const weekdayCodes = normalizeWeekdays(intake.weekdays);
const frequencyText =
getIntakeScheduleMode(intake) === "weekdays"
? weekdayCodes.map(getWeekdayIcsCode).join(", ")
: getIntakeFrequencyText(intake, (key, options) => {
if (key === "common.daily") return "daily";
if (key === "common.everyNDays") return `every ${options?.count ?? interval} days`;
return key;
});
const rrule =
getIntakeScheduleMode(intake) === "weekdays" && weekdayCodes.length > 0
? `RRULE:FREQ=WEEKLY;BYDAY=${weekdayCodes.map(getWeekdayIcsCode).join(",")}`
: `RRULE:FREQ=DAILY;INTERVAL=${interval}`;
const description = [
`Medication: ${displayName}`,
med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`,
`Frequency: every ${interval} day${interval !== 1 ? "s" : ""}`,
`Frequency: ${frequencyText}`,
med.notes ? `Notes: ${med.notes}` : "",
]
.filter(Boolean)
@@ -44,7 +64,7 @@ UID:medassist-ng-${med.id}-${idx}@medassist-ng
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(start)}
DTEND:${formatICSDate(end)}
RRULE:FREQ=DAILY;INTERVAL=${interval}
${rrule}
SUMMARY:${summary}
DESCRIPTION:${description}
BEGIN:VALARM
+139
View File
@@ -0,0 +1,139 @@
import type { Blister, Intake, ScheduleMode, WeekdayCode } from "../types";
type MedicationScheduleSource = {
intakes?: Intake[] | null;
blisters: Blister[];
intakeRemindersEnabled?: boolean;
};
type IntakeScheduleLike = {
every?: number | string | null;
scheduleMode?: ScheduleMode | null;
weekdays?: ReadonlyArray<WeekdayCode> | null;
};
type Translate = (key: string, options?: Record<string, unknown>) => string;
export const WEEKDAY_CODES: WeekdayCode[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const WEEKDAY_LABELS: Record<WeekdayCode, { short: string; long: string; ics: string }> = {
mon: { short: "form.blisters.weekdaysShort.mon", long: "form.blisters.weekdaysLong.mon", ics: "MO" },
tue: { short: "form.blisters.weekdaysShort.tue", long: "form.blisters.weekdaysLong.tue", ics: "TU" },
wed: { short: "form.blisters.weekdaysShort.wed", long: "form.blisters.weekdaysLong.wed", ics: "WE" },
thu: { short: "form.blisters.weekdaysShort.thu", long: "form.blisters.weekdaysLong.thu", ics: "TH" },
fri: { short: "form.blisters.weekdaysShort.fri", long: "form.blisters.weekdaysLong.fri", ics: "FR" },
sat: { short: "form.blisters.weekdaysShort.sat", long: "form.blisters.weekdaysLong.sat", ics: "SA" },
sun: { short: "form.blisters.weekdaysShort.sun", long: "form.blisters.weekdaysLong.sun", ics: "SU" },
};
export function normalizeWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): WeekdayCode[] {
if (!Array.isArray(weekdays) || weekdays.length === 0) return [];
const normalizedSet = new Set<WeekdayCode>();
for (const day of weekdays) {
if (WEEKDAY_CODES.includes(day)) {
normalizedSet.add(day);
}
}
return WEEKDAY_CODES.filter((day) => normalizedSet.has(day));
}
export function hasSelectedWeekdays(weekdays?: ReadonlyArray<WeekdayCode> | null): boolean {
return normalizeWeekdays(weekdays).length > 0;
}
export function getIntakeScheduleMode(schedule: IntakeScheduleLike): ScheduleMode {
return schedule.scheduleMode === "weekdays" ? "weekdays" : "interval";
}
export function getNormalizedInterval(schedule: IntakeScheduleLike): number {
const parsedEvery = Number(schedule.every);
if (!Number.isFinite(parsedEvery) || parsedEvery <= 0) return 1;
return Math.floor(parsedEvery);
}
export function getWeekdayCode(date: Date): WeekdayCode {
return WEEKDAY_CODES[(date.getDay() + 6) % 7];
}
export function getWeekdayLabel(day: WeekdayCode, t: Translate, format: "short" | "long" = "short"): string {
return t(WEEKDAY_LABELS[day][format]);
}
export function getWeekdayIcsCode(day: WeekdayCode): string {
return WEEKDAY_LABELS[day].ics;
}
export function toggleWeekdaySelection(
weekdays: ReadonlyArray<WeekdayCode> | null | undefined,
day: WeekdayCode
): WeekdayCode[] {
const normalized = normalizeWeekdays(weekdays);
if (normalized.includes(day)) {
return normalized.filter((entry) => entry !== day);
}
return normalizeWeekdays([...normalized, day]);
}
export function getMedicationIntakes(med: MedicationScheduleSource): Intake[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
return med.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
scheduleMode: "interval",
weekdays: [],
intakeUnit: null,
takenBy: null,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
export function iterateIntakeOccurrences(
intake: IntakeScheduleLike,
start: Date,
end: Date,
callback: (occurrence: Date) => void
): void {
if (start > end) return;
if (getIntakeScheduleMode(intake) === "weekdays") {
const weekdays = normalizeWeekdays(intake.weekdays);
if (weekdays.length === 0) return;
const cursor = new Date(start);
while (cursor <= end) {
if (weekdays.includes(getWeekdayCode(cursor))) {
callback(new Date(cursor));
}
cursor.setDate(cursor.getDate() + 1);
}
return;
}
const interval = getNormalizedInterval(intake);
const cursor = new Date(start);
while (cursor <= end) {
callback(new Date(cursor));
cursor.setDate(cursor.getDate() + interval);
}
}
export function getIntakeDailyRate(schedule: IntakeScheduleLike): number {
if (getIntakeScheduleMode(schedule) === "weekdays") {
return normalizeWeekdays(schedule.weekdays).length / 7;
}
return 1 / getNormalizedInterval(schedule);
}
export function getIntakeFrequencyText(schedule: IntakeScheduleLike, t: Translate): string {
if (getIntakeScheduleMode(schedule) === "weekdays") {
return normalizeWeekdays(schedule.weekdays)
.map((day) => getWeekdayLabel(day, t, "short"))
.join(", ");
}
const every = getNormalizedInterval(schedule);
return every === 1 ? t("common.daily") : t("common.everyNDays", { count: every });
}
+15
View File
@@ -0,0 +1,15 @@
import type { IntakeUnit } from "../types";
type Translate = (key: string, options?: Record<string, unknown>) => string;
export function convertLiquidUsageToMl(usage: number, unit: IntakeUnit | null | undefined): number {
if (unit === "tsp") return usage * 5;
if (unit === "tbsp") return usage * 15;
return usage;
}
export function getLiquidCountUnitLabel(unit: IntakeUnit | null | undefined, usage: number, t: Translate): string {
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
return t("form.packageAmountUnitMl");
}
+77 -94
View File
@@ -2,17 +2,10 @@
// Schedule Building and Coverage Calculations
// =============================================================================
import type {
Blister,
Coverage,
Intake,
Medication,
PackageType,
ScheduleEvent,
StockStatus,
StockThresholds,
} from "../types";
import type { Coverage, Intake, Medication, PackageType, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "./intake-schedule";
import { convertLiquidUsageToMl } from "./intake-units";
export function parseLocalDateTime(isoString: string): Date {
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
@@ -39,38 +32,7 @@ function normalizeIntakeUsageForStock(intake: Intake, med: Medication): number {
const isLiquidStock = isLiquidContainerPackageType(med.packageType) || med.medicationForm === "liquid";
if (!isLiquidStock) return usage;
if (intake.intakeUnit === "tsp") return usage * 5;
if (intake.intakeUnit === "tbsp") return usage * 15;
return usage;
}
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
return convertLiquidUsageToMl(usage, intake.intakeUnit);
}
/**
@@ -90,13 +52,13 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
const intakes = getIntakesForMed(med);
const intakes = getMedicationIntakes(med);
intakes.forEach((intake, idx) => {
const start = parseLocalDateTime(intake.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
iterateIntakeOccurrences(intake, start, end, (d) => {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
if (isPast && !includePast) return;
const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
@@ -113,7 +75,7 @@ export function buildSchedulePreview(
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
intakeRemindersEnabled: intake.intakeRemindersEnabled,
});
}
});
});
});
@@ -129,7 +91,7 @@ export function buildSchedulePreview(
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
totalBlisters: meds.reduce((acc, med) => acc + getMedicationIntakes(med).length, 0),
};
}
@@ -147,10 +109,10 @@ export function calculateCoverage(
): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const nowDate = new Date(now);
const coverage: Coverage[] = meds.map((m) => {
const intakes = getIntakesForMed(m);
const blisters = getBlistersForMed(m);
const intakes = getMedicationIntakes(m);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
@@ -165,11 +127,9 @@ export function calculateCoverage(
// one person's dose — do NOT multiply by personCount again.
// For legacy intakes (no takenBy), the intake applies to ALL people.
let dailyRate = 0;
blisters.forEach((_s, idx) => {
const intake = intakes[idx];
if (!intake) return;
intakes.forEach((intake) => {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
const baseRate = usageForStock * getIntakeDailyRate(intake);
if (intake?.takenBy) {
// Per-intake takenBy: this intake is for exactly 1 person
dailyRate += baseRate;
@@ -189,29 +149,11 @@ export function calculateCoverage(
// time (early intake), that dose is also counted as consumed immediately.
// This prevents double-counting: once the scheduled time arrives, the dose
// was already counted via the early-taken path, not again via time.
blisters.forEach((s, blisterIdx) => {
const blisterStart = parseLocalDateTime(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
const intake = intakes[blisterIdx];
if (!intake) return;
intakes.forEach((intake, blisterIdx) => {
const intakeStart = parseLocalDateTime(intake.start);
if (Number.isNaN(intakeStart.getTime())) return;
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// After a stock correction, start counting consumption from the NEXT
// scheduled dose on this blister's grid, because the user's pill count
// already reflects all consumption up to the correction time.
// We align to the schedule grid so that e.g. correction at 15:40 with
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
// tomorrow's dose (24h later as the old code did).
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
@@ -223,18 +165,15 @@ export function calculateCoverage(
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
// Date-only timestamp of the last auto-consumed dose
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
iterateIntakeOccurrences(intake, intakeStart, nowDate, (occurrence) => {
if (occurrence.getTime() <= stockCorrectionCutoff) return;
timeBasedConsumed += usageForStock * peopleForThisIntake.length;
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
occurrence.getFullYear(),
occurrence.getMonth(),
occurrence.getDate()
).getTime();
}
});
// Early intakes: count future doses already marked as taken.
// The cutoff is the later of: last auto-consumed date or stock correction date.
@@ -276,16 +215,15 @@ export function calculateCoverage(
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && blisters[blisterIdx]) {
const intake = intakes[blisterIdx];
if (!intake) return;
const intake = intakes[blisterIdx];
if (medId === m.id && intake) {
const usageForStock = normalizeIntakeUsageForStock(intake, m);
// Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
const intakeStartDate = new Date(intake.start);
const intakeStartDateOnly = new Date(
intakeStartDate.getFullYear(),
intakeStartDate.getMonth(),
intakeStartDate.getDate()
).getTime();
// Use actual takenAt timestamp for stock correction comparison.
@@ -295,8 +233,8 @@ export function calculateCoverage(
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
!Number.isNaN(intakeStartDateOnly) &&
doseTimestamp >= intakeStartDateOnly &&
afterCorrectionOrNoCorrectionMs
) {
consumed += usageForStock;
@@ -618,3 +556,48 @@ export function computeMissedPastDoseIds(
);
return totalPastDoses.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id));
}
export function buildClearMissedPayload(
pastDays: ReadonlyArray<{
date: Date;
meds: ReadonlyArray<{
medName: string;
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
}>;
}>,
medications: ReadonlyArray<{ id: number; name: string; genericName?: string | null; dismissedUntil?: string | null }>,
takenDoses: Set<string>,
dismissedDoses: Set<string>
): { medicationIds: number[]; until: string | null } {
const medicationIds = new Set<number>();
let latestMissedDate: string | null = null;
for (const day of pastDays) {
for (const item of day.meds) {
const med = medications.find((candidate) => getMedDisplayName(candidate as Medication) === item.medName);
if (!med) continue;
const dismissedUntilDate = med.dismissedUntil ?? undefined;
const hasMissedDose = item.doses.some((dose) => {
if (isDoseDismissed(dose.id, dismissedUntilDate)) {
return false;
}
return expandDoseIds([dose]).some((doseId) => !takenDoses.has(doseId) && !dismissedDoses.has(doseId));
});
if (!hasMissedDose) continue;
medicationIds.add(med.id);
const dayDate = day.date.toISOString().slice(0, 10);
if (!latestMissedDate || dayDate > latestMissedDate) {
latestMissedDate = dayDate;
}
}
}
return {
medicationIds: [...medicationIds],
until: latestMissedDate,
};
}
+58 -121
View File
@@ -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
View File
@@ -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": [