Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fded0d42f | |||
| badee6067c | |||
| 6161c14a7b | |||
| 96b2a0c96f | |||
| 7a32b2045e | |||
| 26475fd3d0 | |||
| 63cd9ef19b | |||
| f15c2dd79f | |||
| b0c5d48095 | |||
| 05226cc500 | |||
| 3e4f1440a9 | |||
| d64a833bda | |||
| ba36f67371 | |||
| 2aa6b1f406 | |||
| 3238a22fd6 | |||
| b139660241 | |||
| 259f00e7a0 | |||
| e9f2760815 | |||
| d0e2ee0783 | |||
| c620146c4b | |||
| 33c1095e77 | |||
| 5d657558f7 | |||
| 0c28999c89 | |||
| 2296303236 | |||
| 9a2d42b8b9 | |||
| 088a6c1a05 | |||
| 228fd4cd7e | |||
| e346d60f39 | |||
| afb8e5028c | |||
| 9ab077a037 | |||
| 976d7356ec | |||
| 943148fb49 | |||
| 94bd8bd6e8 | |||
| 0cf1c5353e | |||
| 98cf1ce1d2 | |||
| 75c201cab5 | |||
| 74f079d13e | |||
| fd3b770a81 | |||
| 612aa007aa | |||
| 02af93ec55 |
@@ -7,6 +7,10 @@ body:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
||||
|
||||
Before submitting, please reproduce the issue on the latest released version.
|
||||
Even better: verify it on the current `main` image/tag.
|
||||
The issue may already be fixed in newer builds.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -57,6 +61,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: version_info
|
||||
attributes:
|
||||
label: Version / Image Information
|
||||
description: Provide the app version and, if using Docker, the exact image tag you are running.
|
||||
placeholder: |
|
||||
App version (Settings -> About): vX.Y.Z
|
||||
Docker image tag (if applicable): latest or main
|
||||
Tag guidance: use `latest` for the newest release, or `main` for the newest changes from the main branch (`main` is always as new as or newer than `latest`).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
|
||||
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
|
||||
- Avoid custom inline modal/button patterns that diverge from project design.
|
||||
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
||||
|
||||
### Modal requirements (non-negotiable)
|
||||
|
||||
Every modal/overlay **must** follow these rules:
|
||||
|
||||
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
|
||||
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
|
||||
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
|
||||
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
|
||||
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
|
||||
|
||||
## Decision Heuristics
|
||||
|
||||
1. If an equivalent component exists, reuse it.
|
||||
|
||||
@@ -79,6 +79,7 @@ Thumbs.db
|
||||
.turbo/
|
||||
.roo/
|
||||
.roomodes
|
||||
.claude/
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
doku
|
||||
@@ -18,8 +18,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-558%2F558-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-776%2F776-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-771%2F771-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1771694832866,
|
||||
"tag": "0010_mean_spot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+586
-50
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.1",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -23,12 +23,13 @@
|
||||
"fastify": "^5.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
@@ -99,9 +100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
|
||||
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz",
|
||||
"integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -115,20 +116,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.1",
|
||||
"@biomejs/cli-darwin-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.1",
|
||||
"@biomejs/cli-linux-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.1",
|
||||
"@biomejs/cli-win32-arm64": "2.4.1",
|
||||
"@biomejs/cli-win32-x64": "2.4.1"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.4",
|
||||
"@biomejs/cli-darwin-x64": "2.4.4",
|
||||
"@biomejs/cli-linux-arm64": "2.4.4",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.4",
|
||||
"@biomejs/cli-linux-x64": "2.4.4",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.4",
|
||||
"@biomejs/cli-win32-arm64": "2.4.4",
|
||||
"@biomejs/cli-win32-x64": "2.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -143,9 +144,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -160,9 +161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -177,9 +178,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz",
|
||||
"integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -194,9 +195,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -211,9 +212,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz",
|
||||
"integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -228,9 +229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -245,9 +246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -268,6 +269,16 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
@@ -1500,6 +1511,471 @@
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
@@ -2148,18 +2624,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4912,6 +5388,59 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -5240,6 +5769,13 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
@@ -5289,9 +5825,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -32,12 +32,13 @@
|
||||
"fastify": "^5.7.4",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
|
||||
@@ -111,6 +111,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added for intake automation auditability (manual vs automatic taken)
|
||||
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
|
||||
@@ -163,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { resolve } from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
@@ -45,6 +47,16 @@ import {
|
||||
parseCorsOrigins,
|
||||
} from "./utils/server-config.js";
|
||||
|
||||
function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
|
||||
const rawHeader = headers["x-correlation-id"];
|
||||
if (typeof rawHeader !== "string") return null;
|
||||
const trimmed = rawHeader.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length > 128) return null;
|
||||
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Create and configure Fastify app (without starting) */
|
||||
export async function createApp(options?: {
|
||||
logLevel?: string;
|
||||
@@ -73,6 +85,13 @@ export async function createApp(options?: {
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: opts.logLevel },
|
||||
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
// Build config
|
||||
@@ -141,6 +160,13 @@ const app = Fastify({
|
||||
logger: {
|
||||
level: env.LOG_LEVEL,
|
||||
},
|
||||
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
|
||||
});
|
||||
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
done();
|
||||
});
|
||||
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
|
||||
+32
-35
@@ -1,4 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import argon2 from "argon2";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
|
||||
// =============================================================================
|
||||
// Argon2id Configuration - State of the Art Password Hashing
|
||||
@@ -53,6 +60,7 @@ const sensitiveRateLimitConfig = {
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(50, "Username must be at most 50 characters")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
||||
@@ -63,7 +71,7 @@ const registerSchema = z.object({
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
username: z.string().trim().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional().default(false),
|
||||
});
|
||||
@@ -81,6 +89,8 @@ const updateProfileSchema = z.object({
|
||||
// Auth Routes
|
||||
// =============================================================================
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// Token TTLs
|
||||
const accessTtlMinutes = 15;
|
||||
const refreshTtlDays = 14;
|
||||
@@ -461,36 +471,35 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: "No file uploaded" });
|
||||
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = data.filename.split(".").pop() || "jpg";
|
||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
||||
let uploadBuffer: Buffer;
|
||||
try {
|
||||
uploadBuffer = await streamToBuffer(data.file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Save file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const imagesDir = path.join(getDataDir(), "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old avatar if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
try {
|
||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Update user
|
||||
@@ -521,13 +530,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
|
||||
// Update user
|
||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
||||
@@ -554,13 +557,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// Delete avatar file if exists
|
||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||
if (user?.avatarUrl) {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
try {
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||
}
|
||||
|
||||
// Delete user - cascade delete handles all related data
|
||||
|
||||
+129
-7
@@ -2,10 +2,11 @@ import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||
import { doseTracking, medications, shareTokens } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
@@ -22,6 +23,13 @@ const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
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> {
|
||||
@@ -38,6 +46,91 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) return null;
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
async function getActiveShareToken(token: string): Promise<{
|
||||
share: typeof shareTokens.$inferSelect | null;
|
||||
reason: "not_found" | "expired" | "ok";
|
||||
}> {
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) return { share: null, reason: "not_found" };
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
return { share: null, reason: "expired" };
|
||||
}
|
||||
|
||||
return { share, reason: "ok" };
|
||||
}
|
||||
|
||||
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const medTakenBy = parseTakenByJson(medication.takenByJson);
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
if (!intake) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
|
||||
if (expectedPersons.length === 0) {
|
||||
return parsedDose.personSuffix === null;
|
||||
}
|
||||
|
||||
if (!parsedDose.personSuffix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
@@ -56,6 +149,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
@@ -94,6 +188,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null, // Marked by the user themselves
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
@@ -213,9 +308,9 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
@@ -227,6 +322,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
})),
|
||||
};
|
||||
@@ -249,12 +345,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (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})`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -262,6 +366,7 @@ 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})`);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
@@ -270,8 +375,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -282,12 +392,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (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})`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
// Check if this dose was dismissed
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -296,9 +414,13 @@ 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})`);
|
||||
} 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})`
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -72,6 +72,7 @@ const doseHistorySchema = z.object({
|
||||
scheduledTime: z.string(), // ISO datetime
|
||||
takenAt: z.string(), // ISO datetime
|
||||
markedBy: z.string().nullable().optional(),
|
||||
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||
dismissed: z.boolean().default(false),
|
||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||
});
|
||||
@@ -364,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
scheduledTime: scheduledTimeIso,
|
||||
takenAt: takenAtIso,
|
||||
markedBy: dose.markedBy,
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
};
|
||||
@@ -625,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { and, eq, like } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
removeImageFiles,
|
||||
streamToBuffer,
|
||||
writeOptimizedImageSet,
|
||||
} from "../utils/image-upload.js";
|
||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
@@ -693,10 +697,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(imagePath)) unlinkSync(imagePath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
const deleted = await db
|
||||
.delete(medications)
|
||||
@@ -719,24 +720,31 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const data = await req.file();
|
||||
if (!data) return reply.badRequest("No file uploaded");
|
||||
if (!data) return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
|
||||
|
||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
if (!allowedTypes.includes(data.mimetype)) {
|
||||
return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF");
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||
}
|
||||
|
||||
const ext = extname(data.filename) || ".jpg";
|
||||
const filename = `med-${idNum}-${Date.now()}${ext}`;
|
||||
const filepath = resolve(IMAGES_DIR, filename);
|
||||
let uploadBuffer: Buffer;
|
||||
try {
|
||||
uploadBuffer = await streamToBuffer(data.file);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
|
||||
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pipeline(data.file, createWriteStream(filepath));
|
||||
let filename: string;
|
||||
try {
|
||||
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `med-${idNum}`, uploadBuffer));
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||
}
|
||||
|
||||
// Delete old image if exists
|
||||
if (existing.imageUrl) {
|
||||
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(oldPath)) unlinkSync(oldPath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
@@ -758,10 +766,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
if (existing.imageUrl) {
|
||||
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
|
||||
if (existsSync(filepath)) unlinkSync(filepath);
|
||||
}
|
||||
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||
|
||||
await db
|
||||
.update(medications)
|
||||
@@ -792,26 +797,38 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const [settingsRow] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
// Create a map of medication ID to taken dose count
|
||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
takenDoses.forEach((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
||||
if (!takenDosesMap.has(medId)) {
|
||||
takenDosesMap.set(medId, []);
|
||||
}
|
||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
||||
}
|
||||
if (parts.length < 3) return;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) return;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
let takenAtMs: number;
|
||||
if (Number.isFinite(rawTakenAt)) {
|
||||
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||
} else {
|
||||
takenAtMs = new Date(dose.takenAt).getTime();
|
||||
}
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
});
|
||||
|
||||
// Use current time as the reference point for "available" stock
|
||||
@@ -838,66 +855,109 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
? looseTablets + stockAdjustment
|
||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||
|
||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
||||
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
// Build a Set of taken dose IDs for quick lookup
|
||||
const takenDoseIds = new Set(
|
||||
takenDoses
|
||||
.filter((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
||||
})
|
||||
.map((dose) => dose.doseId)
|
||||
);
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
// After a stock correction, start counting from the NEXT scheduled
|
||||
// dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart.getTime();
|
||||
}
|
||||
if (effectiveStart > now.getTime()) return;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intakePerson) {
|
||||
peopleForThisIntake = [intakePerson];
|
||||
} else if (fallbackPeople.length > 0) {
|
||||
peopleForThisIntake = fallbackPeople;
|
||||
} else {
|
||||
peopleForThisIntake = [null];
|
||||
}
|
||||
|
||||
// Get the people for this intake (from intakes array or medication takenBy)
|
||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null];
|
||||
const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback;
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
// Generate expected dose IDs and check if they're taken
|
||||
for (let i = 0; i < occurrences; i++) {
|
||||
const doseDate = new Date(effectiveStart + i * period);
|
||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
// Check if each person has taken this dose
|
||||
for (const person of peopleForThisIntake) {
|
||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
if (takenDoseIds.has(doseId)) {
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumedUntilNow += blister.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||
|
||||
@@ -943,6 +1003,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
medicationId: row.id,
|
||||
medicationName: row.name,
|
||||
totalPills: currentStock,
|
||||
currentPills: currentStock,
|
||||
plannerUsage: usageTotal,
|
||||
blisterSize: pillsPerBlister,
|
||||
blistersNeeded,
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /auth/oidc/login - Initiates OIDC flow
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
||||
app.get("/auth/oidc/login", async (request, reply) => {
|
||||
try {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
});
|
||||
@@ -120,7 +120,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
|
||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
@@ -131,14 +131,14 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
console.error("[OIDC] State mismatch");
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
console.error("[OIDC] Missing code verifier");
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Get user info
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
console.error("[OIDC] Missing sub claim in token");
|
||||
request.log.error("[OIDC] Missing sub claim in token response");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
@@ -174,7 +174,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
|
||||
request.log.error(
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
@@ -214,7 +217,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
@@ -255,7 +258,7 @@ async function findOrCreateOIDCUser(
|
||||
|
||||
// Check if auto-create is enabled
|
||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
||||
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
|
||||
// No logger is available in this helper, route-level logs already capture callback failures.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
const newPackCount = med.packCount + effectivePacksAdded;
|
||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
|
||||
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
|
||||
let consumedRefills = 0;
|
||||
if (usePrescription) {
|
||||
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||
}
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
|
||||
@@ -51,17 +51,22 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
doseId: doseTracking.doseId,
|
||||
takenAt: doseTracking.takenAt,
|
||||
dismissed: doseTracking.dismissed,
|
||||
takenSource: doseTracking.takenSource,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
|
||||
// Group doses by medication ID
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean }[]>();
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||
for (const dose of allDoses) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, dismissed: dose.dismissed });
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
dismissed: dose.dismissed,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch refill history for requested medications
|
||||
@@ -69,6 +74,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
number,
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
@@ -79,6 +85,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
for (const medId of medicationIds) {
|
||||
const doses = dosesByMed.get(medId) ?? [];
|
||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||
|
||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||
@@ -88,6 +95,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
automaticDosesTaken: automaticTakenDoses.length,
|
||||
dosesDismissed: dismissedDoses.length,
|
||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||
|
||||
+40
-12
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -14,9 +14,6 @@ import {
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Share token validity: 1 year in milliseconds
|
||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
// =============================================================================
|
||||
@@ -25,6 +22,11 @@ const createShareSchema = z.object({
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
});
|
||||
|
||||
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> {
|
||||
@@ -54,6 +56,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)}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -62,6 +65,9 @@ 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})`
|
||||
);
|
||||
// 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));
|
||||
return reply.status(410).send({
|
||||
@@ -197,25 +203,47 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique token (8 bytes = 16 hex chars)
|
||||
// Keep exactly one active share link per person/user.
|
||||
// If a link already exists, return the same token and only update settings.
|
||||
const [existingShare] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||
|
||||
if (existingShare) {
|
||||
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})`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
|
||||
// Set expiration date (1 year from now)
|
||||
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
|
||||
|
||||
// Create share token
|
||||
await db.insert(shareTokens).values({
|
||||
userId: userId,
|
||||
userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt,
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
expiresAt: null,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -50,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||
const intakeDate = intake.intakeTime;
|
||||
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||
if (intake.takenBy) {
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||
}
|
||||
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||
}
|
||||
|
||||
async function autoMarkDueIntakesAsTaken(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
locale: string,
|
||||
tz: string,
|
||||
logger: ServiceLogger
|
||||
): Promise<number> {
|
||||
if (settings.stockCalculationMode !== "automatic") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
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);
|
||||
|
||||
const existingToday = await db
|
||||
.select({ doseId: doseTracking.doseId })
|
||||
.from(doseTracking)
|
||||
.where(
|
||||
and(
|
||||
eq(doseTracking.userId, settings.userId),
|
||||
gte(doseTracking.takenAt, todayStart),
|
||||
lte(doseTracking.takenAt, todayEnd)
|
||||
)
|
||||
);
|
||||
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||
|
||||
let inserted = 0;
|
||||
|
||||
for (const med of rows) {
|
||||
if (med.isObsolete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakesJson,
|
||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||
med.intakeRemindersEnabled ?? false
|
||||
);
|
||||
if (intakes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const todaysIntakes = getTodaysIntakes(
|
||||
med.name,
|
||||
intakes,
|
||||
medicationTakenBy,
|
||||
med.pillWeightMg,
|
||||
locale,
|
||||
tz,
|
||||
med.id,
|
||||
med.doseUnit ?? "mg"
|
||||
);
|
||||
|
||||
for (const intake of todaysIntakes) {
|
||||
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
|
||||
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
|
||||
continue;
|
||||
}
|
||||
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doseId = buildDoseIdForIntake({
|
||||
...intake,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
});
|
||||
|
||||
if (existingDoseIds.has(doseId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
takenAt: intake.intakeTime,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
existingDoseIds.add(doseId);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted > 0) {
|
||||
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function sendIntakeReminderEmail(
|
||||
email: string,
|
||||
intakes: UpcomingIntake[],
|
||||
@@ -246,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||
|
||||
// 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;
|
||||
@@ -262,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
);
|
||||
|
||||
// Get all medications with intake reminders enabled for this user
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, settings.userId))
|
||||
.orderBy(medications.id);
|
||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||
|
||||
if (medsWithReminders.length === 0) {
|
||||
@@ -280,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
|
||||
const state = loadIntakeReminderState();
|
||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
const locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import type { ServiceLogger } from "../utils/logger.js";
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
parseBlisters,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
type ReminderState,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
@@ -38,6 +40,56 @@ function escapeHtml(text: string): string {
|
||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||
|
||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||
|
||||
function ensureReminderLocksDir(): void {
|
||||
if (!existsSync(reminderLocksDir)) {
|
||||
mkdirSync(reminderLocksDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function acquireReminderSendLock(lockKey: string): string | null {
|
||||
ensureReminderLocksDir();
|
||||
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
|
||||
|
||||
const tryCreateLock = (): boolean => {
|
||||
try {
|
||||
const fd = openSync(lockFilePath, "wx");
|
||||
closeSync(fd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (tryCreateLock()) {
|
||||
return lockFilePath;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = statSync(lockFilePath);
|
||||
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||
unlinkSync(lockFilePath);
|
||||
if (tryCreateLock()) {
|
||||
return lockFilePath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore; lock acquisition fails safely
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||
if (!lockFilePath) return;
|
||||
try {
|
||||
unlinkSync(lockFilePath);
|
||||
} catch {
|
||||
// ignore release errors
|
||||
}
|
||||
}
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
@@ -119,10 +171,6 @@ export async function updateUserReminderSentTime(
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
@@ -142,7 +190,8 @@ async function getMedicationsNeedingReminder(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -150,15 +199,152 @@ async function getMedicationsNeedingReminder(
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const takenDoseRows = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
for (const dose of takenDoseRows) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
let takenAtMs: number;
|
||||
if (Number.isFinite(rawTakenAt)) {
|
||||
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||
} else {
|
||||
takenAtMs = new Date(dose.takenAt).getTime();
|
||||
}
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
}
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills =
|
||||
const intakes = parseIntakesJson(
|
||||
row.intakesJson,
|
||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||
row.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
|
||||
const originalTotalPills =
|
||||
(row.packageType ?? "blister") === "bottle"
|
||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
let consumed = 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
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 intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intakePerson) {
|
||||
peopleForThisIntake = [intakePerson];
|
||||
} else if (fallbackPeople.length > 0) {
|
||||
peopleForThisIntake = fallbackPeople;
|
||||
} else {
|
||||
peopleForThisIntake = [null];
|
||||
}
|
||||
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumed += blister.usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||
|
||||
if (daysLeft === null) continue;
|
||||
|
||||
@@ -168,7 +354,7 @@ async function getMedicationsNeedingReminder(
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: totalPills,
|
||||
medsLeft: currentPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
isCritical,
|
||||
@@ -200,6 +386,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
||||
}));
|
||||
}
|
||||
|
||||
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||
export async function getMedicationsNeedingReminderForTests(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical: boolean;
|
||||
}>
|
||||
> {
|
||||
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||
}
|
||||
|
||||
async function sendReminderEmail(
|
||||
email: string,
|
||||
lowStock: LowStockItem[],
|
||||
@@ -362,6 +567,15 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
||||
}
|
||||
|
||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
||||
// Track stock-scheduler daily execution separately from intake updates.
|
||||
// This prevents intake reminders from suppressing stock catch-up after restarts.
|
||||
const state = loadReminderState();
|
||||
const today = getTodayInTimezone();
|
||||
saveReminderState({
|
||||
...state,
|
||||
lastStockSchedulerCheckDate: today,
|
||||
});
|
||||
|
||||
// Get all user settings to iterate over each user
|
||||
const allUserSettings = await getAllUserSettings();
|
||||
|
||||
@@ -403,172 +617,192 @@ async function checkAndSendReminderForUser(
|
||||
settings.userId,
|
||||
settings.reminderDaysBefore,
|
||||
settings.lowStockDays,
|
||||
language
|
||||
language,
|
||||
settings.stockCalculationMode
|
||||
);
|
||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||
|
||||
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||
);
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
if (stockEmailEnabled) {
|
||||
const result = await sendReminderEmail(
|
||||
settings.notificationEmail!,
|
||||
allLowStock,
|
||||
language,
|
||||
settings.repeatDailyReminders
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stockPushEnabled) {
|
||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (criticalMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||
if (!stockSendLock) {
|
||||
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||
} else {
|
||||
try {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||
);
|
||||
}
|
||||
if (lowStockMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowStockMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||
if (stockEmailEnabled) {
|
||||
const result = await sendReminderEmail(
|
||||
settings.notificationEmail!,
|
||||
allLowStock,
|
||||
language,
|
||||
settings.repeatDailyReminders
|
||||
);
|
||||
emailSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (stockPushEnabled) {
|
||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||
|
||||
const titleParts: string[] = [];
|
||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyMeds.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||
}
|
||||
if (criticalMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||
criticalMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (lowStockMeds.length > 0) {
|
||||
if (messageParts.length > 0) messageParts.push("");
|
||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||
lowStockMeds.forEach((m) =>
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "stock",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||
}
|
||||
} finally {
|
||||
releaseReminderSendLock(stockSendLock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||
);
|
||||
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||
if (!prescriptionSendLock) {
|
||||
logger.debug(
|
||||
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
logger.info(
|
||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||
);
|
||||
|
||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||
const lines = allPrescriptionLow.map((m) => {
|
||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||
if (m.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
name: m.name,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
}
|
||||
return `- ${t(tr.prescriptionReminder.line, {
|
||||
name: m.name,
|
||||
refills: m.remainingRefills,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
});
|
||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||
const lines = allPrescriptionLow.map((m) => {
|
||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||
if (m.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
name: m.name,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
}
|
||||
return `- ${t(tr.prescriptionReminder.line, {
|
||||
name: m.name,
|
||||
refills: m.remainingRefills,
|
||||
expirySuffix,
|
||||
})}`;
|
||||
});
|
||||
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
let emailSuccess = false;
|
||||
let shoutrrrSuccess = false;
|
||||
|
||||
if (prescriptionEmailEnabled) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
if (prescriptionEmailEnabled) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
|
||||
if (smtpHost && smtpUser) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||
});
|
||||
if (smtpHost && smtpUser) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||
});
|
||||
|
||||
const subject =
|
||||
allPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||
const subject =
|
||||
allPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
const bodyText =
|
||||
emptyRx.length > 0
|
||||
? tr.prescriptionReminder.descriptionEmpty
|
||||
: tr.prescriptionReminder.descriptionLow;
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const tableRows = allPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
const safeRefills = Number(item.remainingRefills) || 0;
|
||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
const tableRows = allPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
const safeRefills = Number(item.remainingRefills) || 0;
|
||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("");
|
||||
|
||||
const html = `
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
||||
@@ -608,80 +842,86 @@ async function checkAndSendReminderForUser(
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: settings.notificationEmail!,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
to: settings.notificationEmail!,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
emailSuccess = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prescriptionPushEnabled) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "prescription",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
emailSuccess = true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
||||
|
||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||
}
|
||||
} finally {
|
||||
releaseReminderSendLock(prescriptionSendLock);
|
||||
}
|
||||
}
|
||||
|
||||
if (prescriptionPushEnabled) {
|
||||
const titleParts: string[] = [];
|
||||
if (emptyRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||
);
|
||||
if (lowRx.length > 0)
|
||||
titleParts.push(
|
||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||
);
|
||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||
|
||||
const messageParts: string[] = [];
|
||||
if (emptyRx.length > 0) {
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||
for (const m of emptyRx) {
|
||||
messageParts.push(` • ${m.name}`);
|
||||
}
|
||||
}
|
||||
if (lowRx.length > 0) {
|
||||
if (emptyRx.length > 0) messageParts.push("");
|
||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||
for (const m of lowRx) {
|
||||
messageParts.push(
|
||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||
lastNotificationType: "prescription",
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||
let schedulerStarted = false;
|
||||
|
||||
function scheduleNextCheck(logger: ServiceLogger): void {
|
||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||
@@ -706,6 +946,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
|
||||
}
|
||||
|
||||
export function startReminderScheduler(logger: ServiceLogger): void {
|
||||
if (schedulerStarted) {
|
||||
logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`);
|
||||
return;
|
||||
}
|
||||
schedulerStarted = true;
|
||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||
|
||||
// Check if we need to run immediately (missed today's check)
|
||||
@@ -713,9 +958,10 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
||||
const today = getTodayInTimezone();
|
||||
const currentHour = getCurrentHourInTimezone();
|
||||
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running now...");
|
||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run one catch-up.
|
||||
// This is intentionally a single current-state snapshot (no replay of missed days).
|
||||
if (currentHour >= REMINDER_HOUR && state.lastStockSchedulerCheckDate !== today) {
|
||||
logger.info("[Reminder] Missed today's check, running one catch-up snapshot (no historical replay)...");
|
||||
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||
}
|
||||
|
||||
@@ -725,9 +971,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||
}
|
||||
|
||||
export async function runReminderSchedulerNow(logger: ServiceLogger): Promise<void> {
|
||||
logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`);
|
||||
await checkAndSendReminder(logger);
|
||||
}
|
||||
|
||||
export function stopReminderScheduler(): void {
|
||||
if (schedulerTimeout) {
|
||||
clearTimeout(schedulerTimeout);
|
||||
schedulerTimeout = null;
|
||||
}
|
||||
schedulerStarted = false;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should register with trimmed username when input has whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " trimuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json().user.username).toBe("trimuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on registration", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should reject duplicate username even with surrounding whitespace", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: "spacedupe",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: {
|
||||
username: " spacedupe ",
|
||||
password: "AnotherPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().code).toBe("USERNAME_EXISTS");
|
||||
});
|
||||
|
||||
it("should reject invalid username characters", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -341,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
||||
});
|
||||
|
||||
it("should login successfully when username has leading/trailing whitespace", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " loginuser ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().ok).toBe(true);
|
||||
expect(response.json().user.username).toBe("loginuser");
|
||||
});
|
||||
|
||||
it("should reject whitespace-only username on login", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/login",
|
||||
payload: {
|
||||
username: " ",
|
||||
password: "TestPassword123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should support rememberMe option", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -171,6 +171,7 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
@@ -152,8 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
});
|
||||
|
||||
// POST /import
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||
app.post("/import", async (request, reply) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||
const importData = request.body as any;
|
||||
|
||||
// Basic validation
|
||||
|
||||
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
|
||||
dose_id text NOT NULL,
|
||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
marked_by text,
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type OidcMocks = {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: async () => 1,
|
||||
}));
|
||||
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function seedAnonymousUser() {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [1, "anon", "anonymous"],
|
||||
});
|
||||
}
|
||||
|
||||
async function setStockMode(mode: "automatic" | "manual") {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||
VALUES (?, ?, 7, 365, 'en')`,
|
||||
args: [1, mode],
|
||||
});
|
||||
}
|
||||
|
||||
async function createMedication(options: {
|
||||
name: string;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: number | null;
|
||||
isObsolete?: boolean;
|
||||
takenBy?: string[];
|
||||
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
stockAdjustment = 0,
|
||||
lastStockCorrectionAt = null,
|
||||
isObsolete = false,
|
||||
takenBy = [],
|
||||
intakes,
|
||||
} = options;
|
||||
|
||||
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||
const intakesJson = JSON.stringify(
|
||||
intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
takenBy: i.takenBy ?? null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, taken_by_json, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
intakesJson,
|
||||
isObsolete ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function markDoseTaken(options: {
|
||||
medicationId: number;
|
||||
blisterIdx: number;
|
||||
doseDateOnlyMs: number;
|
||||
takenAtMs: number;
|
||||
personSuffix?: string;
|
||||
}) {
|
||||
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||
});
|
||||
}
|
||||
|
||||
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate, endDate },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const rows = response.json();
|
||||
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||
expect(row).toBeDefined();
|
||||
return row;
|
||||
}
|
||||
|
||||
function toDateOnlyMs(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
}
|
||||
|
||||
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(medicationRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
await seedAnonymousUser();
|
||||
});
|
||||
|
||||
it("keeps automatic mode current stock in sync", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Auto Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(10);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Correction";
|
||||
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
lastStockCorrectionAt: correctionMs,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan5DateOnly,
|
||||
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||
});
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan6DateOnly,
|
||||
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("counts early taken dose in automatic mode without drift", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Early Taken";
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
tomorrow.setHours(20, 0, 0, 0);
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||
});
|
||||
|
||||
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: tomorrowDateOnly,
|
||||
takenAtMs: now.getTime(),
|
||||
});
|
||||
|
||||
const rangeStart = new Date(now);
|
||||
rangeStart.setDate(now.getDate() - 1);
|
||||
const rangeEnd = new Date(now);
|
||||
rangeEnd.setDate(now.getDate() + 7);
|
||||
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(9);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Mixed TakenBy";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||
],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
expect(usageRow.currentPills).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||
await setStockMode("automatic");
|
||||
await createMedication({
|
||||
name: "Obsolete Med",
|
||||
isObsolete: true,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -22,6 +22,7 @@ declare module "fastify" {
|
||||
|
||||
interface FastifyRequest {
|
||||
user?: AuthUser | null;
|
||||
correlationId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { extname, resolve } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function getThumbFilename(imageFilename: string): string {
|
||||
const ext = extname(imageFilename);
|
||||
const base = ext ? imageFilename.slice(0, -ext.length) : imageFilename;
|
||||
return `${base}-thumb.webp`;
|
||||
}
|
||||
|
||||
export function removeImageFiles(imagesDir: string, imageFilename: string): void {
|
||||
const fullPath = resolve(imagesDir, imageFilename);
|
||||
if (existsSync(fullPath)) unlinkSync(fullPath);
|
||||
|
||||
const thumbFilename = getThumbFilename(imageFilename);
|
||||
if (thumbFilename !== imageFilename) {
|
||||
const thumbPath = resolve(imagesDir, thumbFilename);
|
||||
if (existsSync(thumbPath)) unlinkSync(thumbPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalSize += buffer.length;
|
||||
if (totalSize > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
throw new Error("IMAGE_TOO_LARGE");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function writeOptimizedImageSet(
|
||||
imagesDir: string,
|
||||
filePrefix: string,
|
||||
uploadBuffer: Buffer,
|
||||
options?: {
|
||||
maxEdgePx?: number;
|
||||
thumbSizePx?: number;
|
||||
fullQuality?: number;
|
||||
thumbQuality?: number;
|
||||
}
|
||||
): Promise<{ filename: string; thumbFilename: string }> {
|
||||
const maxEdgePx = options?.maxEdgePx ?? 1600;
|
||||
const thumbSizePx = options?.thumbSizePx ?? 96;
|
||||
const fullQuality = options?.fullQuality ?? 82;
|
||||
const thumbQuality = options?.thumbQuality ?? 76;
|
||||
|
||||
const filename = `${filePrefix}-${Date.now()}.webp`;
|
||||
const thumbFilename = getThumbFilename(filename);
|
||||
|
||||
const filepath = resolve(imagesDir, filename);
|
||||
const thumbFilepath = resolve(imagesDir, thumbFilename);
|
||||
|
||||
const optimizedBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: maxEdgePx, height: maxEdgePx, fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: fullQuality })
|
||||
.toBuffer();
|
||||
|
||||
const thumbBuffer = await sharp(uploadBuffer, { failOn: "error" })
|
||||
.rotate()
|
||||
.resize({ width: thumbSizePx, height: thumbSizePx, fit: "cover", position: "attention" })
|
||||
.webp({ quality: thumbQuality })
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(filepath, optimizedBuffer);
|
||||
await writeFile(thumbFilepath, thumbBuffer);
|
||||
|
||||
return { filename, thumbFilename };
|
||||
}
|
||||
@@ -483,6 +483,7 @@ export function getUpcomingIntakes(
|
||||
export type ReminderState = {
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
lastStockSchedulerCheckDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
||||
@@ -505,6 +506,7 @@ export function createDefaultReminderState(): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
lastStockSchedulerCheckDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
@@ -524,6 +526,7 @@ export function parseReminderState(json: string): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# GitHub Project Setup
|
||||
|
||||
This repository includes a GitHub Actions workflow that automatically adds new issues to a GitHub Project for tracking feature requests and bugs.
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Create a GitHub Project
|
||||
|
||||
1. Go to your GitHub profile → **Projects** → **New project**
|
||||
2. Choose the **Board** template (recommended for feature tracking)
|
||||
3. Name it e.g. **MedAssist-ng Roadmap**
|
||||
4. Configure the default columns:
|
||||
- **Triage** – New issues land here
|
||||
- **Backlog** – Accepted but not yet started
|
||||
- **In Progress** – Currently being worked on
|
||||
- **Done** – Completed
|
||||
|
||||
### 2. Create a Personal Access Token (PAT)
|
||||
|
||||
The workflow needs a token with project permissions. The built-in `GITHUB_TOKEN` does not support GitHub Projects.
|
||||
|
||||
1. Go to **Settings** → **Developer settings** → **Personal access tokens** → **Fine-grained tokens**
|
||||
2. Click **Generate new token**
|
||||
3. Set:
|
||||
- **Token name**: `add-to-project`
|
||||
- **Expiration**: Choose an appropriate duration
|
||||
- **Repository access**: Select **Only select repositories** → `DanielVolz/medassist-ng`
|
||||
- **Permissions**:
|
||||
- Repository permissions: **Issues** → Read
|
||||
- Organization permissions (if applicable): **Projects** → Read and write
|
||||
- For **user-owned projects**, you need a **classic** token with the `project` scope instead
|
||||
4. Copy the generated token
|
||||
|
||||
### 3. Add Repository Secrets and Variables
|
||||
|
||||
1. Go to the repository → **Settings** → **Secrets and variables** → **Actions**
|
||||
2. Add a **secret**:
|
||||
- Name: `ADD_TO_PROJECT_PAT`
|
||||
- Value: The PAT from step 2
|
||||
3. Add a **variable** (under the **Variables** tab):
|
||||
- Name: `PROJECT_URL`
|
||||
- Value: The full URL of your GitHub Project (e.g. `https://github.com/users/DanielVolz/projects/1`)
|
||||
|
||||
### 4. Verify
|
||||
|
||||
1. Create a test issue using the **✨ Feature Request** template
|
||||
2. Check the **Actions** tab to see the workflow run
|
||||
3. Verify the issue appears in your GitHub Project under **Triage**
|
||||
|
||||
## How It Works
|
||||
|
||||
The workflow (`.github/workflows/add-to-project.yml`) triggers when:
|
||||
- A new issue is **opened**
|
||||
- A label is **added** to an existing issue
|
||||
|
||||
Issues with any of these labels are automatically added to the project:
|
||||
- `enhancement` – Feature requests
|
||||
- `bug` – Bug reports
|
||||
- `triage` – New issues needing review
|
||||
|
||||
Both the feature request and bug report issue templates automatically apply the `triage` label, so all new issues from templates are captured.
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding more labels
|
||||
|
||||
Edit `.github/workflows/add-to-project.yml` and add labels to the `labeled` field:
|
||||
|
||||
```yaml
|
||||
labeled: enhancement, bug, triage, documentation
|
||||
```
|
||||
|
||||
### Restricting to feature requests only
|
||||
|
||||
Change the `labeled` field to only include `enhancement`:
|
||||
|
||||
```yaml
|
||||
labeled: enhancement
|
||||
label-operator: OR
|
||||
```
|
||||
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||
* since it tests the unauthenticated flow.
|
||||
*/
|
||||
export const test = base.extend<{}>({
|
||||
export const test = base.extend<object>({
|
||||
page: async ({ page }, use) => {
|
||||
await setupAuthMeMock(page);
|
||||
await use(page);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
test,
|
||||
} from "./fixtures";
|
||||
|
||||
/**
|
||||
* Tooltip Visibility Regression Tests
|
||||
*
|
||||
* Ensures that tooltip pseudo-elements on MedDetail footer icon buttons
|
||||
* are not clipped by ancestor overflow or hidden behind modal overlays.
|
||||
* This is a regression guard — tooltips have repeatedly broken due to
|
||||
* CSS overflow/z-index changes on modal containers.
|
||||
*/
|
||||
test.describe("MedDetail footer tooltip visibility", () => {
|
||||
test.use({ storageState: authFile });
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
const MED_NAME = "Tooltip Test Med";
|
||||
const createdMeds: TestMedication[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: MED_NAME,
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: new Date().toISOString().slice(0, 16),
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the MedDetail modal by clicking a medication row in the Dashboard overview table.
|
||||
*/
|
||||
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
||||
await navigateTo(page, "/dashboard");
|
||||
const overviewTable = page.locator(".table.table-7");
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
||||
await medRow.click();
|
||||
|
||||
const modal = page.locator(".modal-overlay.med-detail-overlay");
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
return modal;
|
||||
}
|
||||
|
||||
test("no ancestor of footer tooltip buttons has overflow:hidden", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const footer = modal.locator(".med-detail-footer");
|
||||
await expect(footer).toBeVisible();
|
||||
|
||||
// Walk up from footer through modal-content to modal-overlay and check overflow
|
||||
const overflowHiddenAncestors = await page.evaluate(() => {
|
||||
const footer = document.querySelector(".med-detail-footer");
|
||||
if (!footer) return ["footer not found"];
|
||||
|
||||
const problems: string[] = [];
|
||||
let el: HTMLElement | null = footer as HTMLElement;
|
||||
while (el && !el.classList.contains("modal-overlay")) {
|
||||
const computed = window.getComputedStyle(el);
|
||||
const overflowX = computed.overflowX;
|
||||
const overflowY = computed.overflowY;
|
||||
if (overflowX === "hidden" || overflowY === "hidden") {
|
||||
const id = el.id ? `#${el.id}` : "";
|
||||
const cls = el.className ? `.${el.className.split(" ").join(".")}` : "";
|
||||
problems.push(`${el.tagName.toLowerCase()}${id}${cls} has overflow: ${overflowX}/${overflowY}`);
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
return problems;
|
||||
});
|
||||
|
||||
expect(
|
||||
overflowHiddenAncestors,
|
||||
`Tooltip ancestors must not clip with overflow:hidden: ${overflowHiddenAncestors.join("; ")}`
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tooltip z-index is above modal overlay", async ({ page }) => {
|
||||
const _modal = await openMedDetailModal(page);
|
||||
|
||||
// Get modal overlay z-index and tooltip pseudo-element z-index from CSS
|
||||
const { modalZIndex, tooltipZIndex, arrowZIndex } = await page.evaluate(() => {
|
||||
const overlay = document.querySelector(".modal-overlay");
|
||||
const overlayZ = overlay ? Number.parseInt(window.getComputedStyle(overlay).zIndex, 10) : 0;
|
||||
|
||||
// Read the tooltip ::after z-index from stylesheets
|
||||
let ttZ = 0;
|
||||
let arrZ = 0;
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
const cssRule = rule as CSSStyleRule;
|
||||
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::after")) {
|
||||
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||
if (z > ttZ) ttZ = z;
|
||||
}
|
||||
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::before")) {
|
||||
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||
if (z > arrZ) arrZ = z;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// cross-origin sheets — skip
|
||||
}
|
||||
}
|
||||
return { modalZIndex: overlayZ, tooltipZIndex: ttZ, arrowZIndex: arrZ };
|
||||
});
|
||||
|
||||
expect(
|
||||
tooltipZIndex,
|
||||
`Tooltip ::after z-index (${tooltipZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||
).toBeGreaterThan(modalZIndex);
|
||||
expect(
|
||||
arrowZIndex,
|
||||
`Tooltip ::before z-index (${arrowZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||
).toBeGreaterThan(modalZIndex);
|
||||
});
|
||||
|
||||
test("edit button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const editBtn = modal.locator(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||
await expect(editBtn).toBeVisible();
|
||||
|
||||
// Hover to activate tooltip
|
||||
await editBtn.hover();
|
||||
// Small wait for CSS transition
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify the tooltip pseudo-element is visible and within viewport
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Edit tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("stock correction button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const stockBtn = modal.locator(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||
await expect(stockBtn).toBeVisible();
|
||||
|
||||
await stockBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Stock correction tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("export button tooltip is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const exportBtn = modal.locator(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||
// Export button only shows when blisters exist — skip if not present
|
||||
if (!(await exportBtn.isVisible().catch(() => false))) {
|
||||
test.skip(true, "Export button not visible (no blisters)");
|
||||
return;
|
||||
}
|
||||
|
||||
await exportBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Export tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
|
||||
test("close button tooltip in header is visible on hover", async ({ page }) => {
|
||||
const modal = await openMedDetailModal(page);
|
||||
|
||||
const closeBtn = modal.locator("button.modal-close.tooltip-trigger");
|
||||
await expect(closeBtn).toBeVisible();
|
||||
|
||||
await closeBtn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const btn = document.querySelector(".med-detail-overlay button.modal-close.tooltip-trigger");
|
||||
if (!btn) return { visible: false, reason: "button not found" };
|
||||
|
||||
const style = window.getComputedStyle(btn, "::after");
|
||||
const opacity = Number.parseFloat(style.opacity);
|
||||
const visibility = style.visibility;
|
||||
|
||||
if (opacity < 0.5 || visibility === "hidden") {
|
||||
return {
|
||||
visible: false,
|
||||
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||
};
|
||||
}
|
||||
return { visible: true, reason: "ok" };
|
||||
});
|
||||
|
||||
expect(isVisible.visible, `Close button tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||
});
|
||||
});
|
||||
+1
-2
@@ -6,7 +6,6 @@
|
||||
<title>MedAssist-ng</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
@@ -14,7 +13,7 @@
|
||||
|
||||
<!-- Theme color -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -14,6 +14,8 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||
|
||||
# Allow larger file uploads (for medication images and data import/export)
|
||||
client_max_body_size 50M;
|
||||
|
||||
Generated
+72
-54
@@ -1,28 +1,29 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.15.1",
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.10",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -405,9 +406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
|
||||
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz",
|
||||
"integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -421,20 +422,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.1",
|
||||
"@biomejs/cli-darwin-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.1",
|
||||
"@biomejs/cli-linux-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.1",
|
||||
"@biomejs/cli-win32-arm64": "2.4.1",
|
||||
"@biomejs/cli-win32-x64": "2.4.1"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.4",
|
||||
"@biomejs/cli-darwin-x64": "2.4.4",
|
||||
"@biomejs/cli-linux-arm64": "2.4.4",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.4",
|
||||
"@biomejs/cli-linux-x64": "2.4.4",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.4",
|
||||
"@biomejs/cli-win32-arm64": "2.4.4",
|
||||
"@biomejs/cli-win32-x64": "2.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -449,9 +450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -466,9 +467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -483,9 +484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz",
|
||||
"integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -500,9 +501,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -517,9 +518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz",
|
||||
"integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -534,9 +535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz",
|
||||
"integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -551,9 +552,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz",
|
||||
"integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1735,6 +1736,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2449,9 +2460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.10",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
|
||||
"integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
|
||||
"version": "25.8.13",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
|
||||
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2640,9 +2651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.574.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz",
|
||||
"integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==",
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2983,9 +2994,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -3005,12 +3016,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.0"
|
||||
"react-router": "7.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -3302,6 +3313,13 @@
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.16.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -25,21 +25,22 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.10",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@biomejs/biome": "^2.4.4",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^18.3.4",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.9 MiB |
+150
-80
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AboutModal,
|
||||
Lightbox,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
||||
import { useScrollLock } from "./hooks/useScrollLock";
|
||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
||||
|
||||
// Vite injects this at build time from package.json
|
||||
@@ -113,14 +114,13 @@ function AppRouter() {
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// Get shared state from AppContext
|
||||
const ctx = useAppContext();
|
||||
const {
|
||||
// Medications
|
||||
meds,
|
||||
loadMeds,
|
||||
// Settings
|
||||
settings,
|
||||
// Refill
|
||||
showRefillModal,
|
||||
setShowRefillModal,
|
||||
@@ -190,59 +190,24 @@ function AppContent() {
|
||||
// Local-only state (not shared across components)
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [routeTransitionMaskActive, setRouteTransitionMaskActive] = useState(false);
|
||||
const routeTransitionMinEndRef = useRef(0);
|
||||
const routeTransitionFallbackTimerRef = useRef<number | null>(null);
|
||||
const closeProfile = useCallback(() => {
|
||||
if (showProfile) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showProfile]);
|
||||
|
||||
const closeAbout = useCallback(() => {
|
||||
if (showAbout) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showAbout]);
|
||||
|
||||
// Get centralized stockThresholds from context
|
||||
const { stockThresholds } = ctx;
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Close modals in order of priority (topmost first)
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
} else if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
} else if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
} else if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
} else if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
} else if (showAbout) {
|
||||
closeAbout();
|
||||
} else if (showProfile) {
|
||||
closeProfile();
|
||||
} else if (selectedUser) {
|
||||
closeUserFilter();
|
||||
} else if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
selectedMed,
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
selectedUser,
|
||||
showProfile,
|
||||
showAbout,
|
||||
showShareDialog,
|
||||
showRefillModal,
|
||||
showEditStockModal,
|
||||
closeAbout,
|
||||
closeEditStockModal,
|
||||
closeImageLightbox,
|
||||
closeMedDetail,
|
||||
closeProfile,
|
||||
closeRefillModal,
|
||||
closeScheduleLightbox,
|
||||
closeShareDialog,
|
||||
closeUserFilter,
|
||||
]);
|
||||
|
||||
// Handle browser back button to close modals (in priority order)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
@@ -331,21 +296,86 @@ function AppContent() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prevent background scroll when modal is open
|
||||
// Global Escape handling in priority order.
|
||||
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||
useEffect(() => {
|
||||
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
|
||||
if (isModalOpen) {
|
||||
document.documentElement.classList.add("modal-open");
|
||||
document.body.classList.add("modal-open");
|
||||
} else {
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
return () => {
|
||||
document.documentElement.classList.remove("modal-open");
|
||||
document.body.classList.remove("modal-open");
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
|
||||
if (scheduleLightboxImage) {
|
||||
closeScheduleLightbox();
|
||||
return;
|
||||
}
|
||||
if (showImageLightbox) {
|
||||
closeImageLightbox();
|
||||
return;
|
||||
}
|
||||
if (showEditStockModal) {
|
||||
closeEditStockModal();
|
||||
return;
|
||||
}
|
||||
if (showRefillModal) {
|
||||
closeRefillModal();
|
||||
return;
|
||||
}
|
||||
if (showShareDialog) {
|
||||
closeShareDialog();
|
||||
return;
|
||||
}
|
||||
if (showAbout) {
|
||||
closeAbout();
|
||||
return;
|
||||
}
|
||||
if (showProfile) {
|
||||
closeProfile();
|
||||
return;
|
||||
}
|
||||
if (selectedUser) {
|
||||
closeUserFilter();
|
||||
return;
|
||||
}
|
||||
if (selectedMed) {
|
||||
closeMedDetail();
|
||||
}
|
||||
};
|
||||
}, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]);
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [
|
||||
showImageLightbox,
|
||||
scheduleLightboxImage,
|
||||
showEditStockModal,
|
||||
showRefillModal,
|
||||
showShareDialog,
|
||||
showAbout,
|
||||
showProfile,
|
||||
selectedUser,
|
||||
selectedMed,
|
||||
closeImageLightbox,
|
||||
closeScheduleLightbox,
|
||||
closeEditStockModal,
|
||||
closeRefillModal,
|
||||
closeShareDialog,
|
||||
closeAbout,
|
||||
closeProfile,
|
||||
closeUserFilter,
|
||||
closeMedDetail,
|
||||
]);
|
||||
|
||||
// Prevent background scroll when any modal is open
|
||||
useScrollLock(
|
||||
!!(
|
||||
selectedMed ||
|
||||
selectedUser ||
|
||||
showProfile ||
|
||||
showAbout ||
|
||||
showShareDialog ||
|
||||
showRefillModal ||
|
||||
showEditStockModal ||
|
||||
showImageLightbox ||
|
||||
scheduleLightboxImage
|
||||
)
|
||||
);
|
||||
|
||||
// Update selectedMed when meds change (e.g., after refill)
|
||||
useEffect(() => {
|
||||
@@ -374,9 +404,57 @@ function AppContent() {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
if (location.pathname !== "/medications") return;
|
||||
|
||||
const hasEditMedIdParam = new URLSearchParams(location.search).has("editMedId");
|
||||
if (hasEditMedIdParam) return;
|
||||
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
const timer = window.setTimeout(() => setRouteTransitionMaskActive(false), remaining);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [location.pathname, location.search, routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEditTransitionReady = () => {
|
||||
if (!routeTransitionMaskActive) return;
|
||||
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||
window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}
|
||||
}, remaining);
|
||||
};
|
||||
|
||||
window.addEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
return () => {
|
||||
window.removeEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||
};
|
||||
}, [routeTransitionMaskActive]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationEdit = () => {
|
||||
if (!selectedMed) return;
|
||||
const medId = selectedMed.id;
|
||||
routeTransitionMinEndRef.current = performance.now() + 80;
|
||||
setRouteTransitionMaskActive(true);
|
||||
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||
}
|
||||
routeTransitionFallbackTimerRef.current = window.setTimeout(() => {
|
||||
setRouteTransitionMaskActive(false);
|
||||
routeTransitionFallbackTimerRef.current = null;
|
||||
}, 700);
|
||||
setShowImageLightbox(false);
|
||||
setShowRefillModal(false);
|
||||
setShowEditStockModal(false);
|
||||
@@ -389,25 +467,15 @@ function AppContent() {
|
||||
openEditStockModal(selectedMed, coverage);
|
||||
};
|
||||
|
||||
function openProfile() {
|
||||
const openProfile = useCallback(() => {
|
||||
setShowProfile(true);
|
||||
window.history.pushState({ modal: "profile" }, "");
|
||||
}
|
||||
function closeProfile() {
|
||||
if (showProfile) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
function openAbout() {
|
||||
const openAbout = useCallback(() => {
|
||||
setShowAbout(true);
|
||||
window.history.pushState({ modal: "about" }, "");
|
||||
}
|
||||
function closeAbout() {
|
||||
if (showAbout) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
@@ -509,6 +577,8 @@ function AppContent() {
|
||||
{scheduleLightboxImage && (
|
||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||
)}
|
||||
|
||||
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: "up-to-date" | "update-available" | "error";
|
||||
@@ -17,6 +18,8 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Reset check result when modal opens so stale results are never shown
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -55,20 +58,22 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content about-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" />
|
||||
<img src="/app-logo.png" alt="MedAssist-ng" />
|
||||
</div>
|
||||
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
||||
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
||||
return (
|
||||
<header className="hero">
|
||||
<div className="hero-title">
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" className="hero-logo" />
|
||||
<img src="/app-logo.png" alt="MedAssist-ng" className="hero-logo" />
|
||||
<div>
|
||||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||||
<h1>{pageInfo.title}</h1>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { withCorrelation } from "../utils/correlation";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
@@ -60,7 +64,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
// Track if initial fetch has been done to prevent duplicate calls
|
||||
const initialFetchDone = useRef(false);
|
||||
|
||||
@@ -70,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
initialFetchDone.current = true;
|
||||
fetchAuthState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [fetchAuthState]);
|
||||
|
||||
// Proactively refresh token every 10 minutes to prevent expiration
|
||||
useEffect(() => {
|
||||
@@ -89,15 +92,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return () => clearInterval(refreshInterval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user, authState?.authEnabled]);
|
||||
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
|
||||
|
||||
async function fetchAuthState(retryCount = 0) {
|
||||
const maxRetries = 3;
|
||||
const retryDelay = 1000; // 1 second
|
||||
let correlationId: string | null = null;
|
||||
|
||||
try {
|
||||
setAuthError(null);
|
||||
const res = await fetch("/api/auth/state");
|
||||
const correlated = withCorrelation(undefined, "fe-auth-state");
|
||||
correlationId = correlated.correlationId;
|
||||
const res = await fetch("/api/auth/state", correlated.init);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server error: ${res.status}`);
|
||||
}
|
||||
@@ -110,7 +116,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
log.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err);
|
||||
log.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err, {
|
||||
correlationId,
|
||||
});
|
||||
|
||||
// Retry on connection errors or 5xx errors (server might be restarting)
|
||||
if (retryCount < maxRetries) {
|
||||
@@ -125,27 +133,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
async function refreshUser() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const { correlationId, init } = withCorrelation({ credentials: "include" }, "fe-auth-me");
|
||||
const res = await fetch("/api/auth/me", init);
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
||||
} else if (res.status === 401) {
|
||||
// Access token expired - try to refresh it
|
||||
log.info("[Auth] Access token invalid, attempting refresh", { correlationId });
|
||||
const refreshed = await tryRefreshToken();
|
||||
if (refreshed) {
|
||||
// Retry /auth/me with new token
|
||||
const retryRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const retry = withCorrelation({ credentials: "include" }, "fe-auth-me-retry");
|
||||
const retryRes = await fetch("/api/auth/me", retry.init);
|
||||
if (retryRes.ok) {
|
||||
const userData = await retryRes.json();
|
||||
setUser(userData);
|
||||
log.info("[Auth] Session restored after token refresh", {
|
||||
userId: userData.id,
|
||||
correlationId: retry.correlationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
||||
setUser(null);
|
||||
} else {
|
||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error("[Auth] Failed to refresh user", { error });
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
@@ -153,31 +172,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Try to refresh the access token using the refresh token
|
||||
async function tryRefreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
},
|
||||
"fe-auth-refresh"
|
||||
);
|
||||
const res = await fetch("/api/auth/refresh", init);
|
||||
if (!res.ok) {
|
||||
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
||||
}
|
||||
return res.ok;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error("[Auth] Token refresh request failed", { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string, rememberMe: boolean = false) {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, password, rememberMe }),
|
||||
},
|
||||
"fe-auth-login"
|
||||
);
|
||||
log.info("[Auth] Login requested", { username, rememberMe, correlationId });
|
||||
const res = await fetch("/api/auth/login", init);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
log.warn("[Auth] Login failed", { username, status: res.status, code: data.code, correlationId });
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
|
||||
}
|
||||
|
||||
async function register(username: string, password: string) {
|
||||
@@ -201,11 +235,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
},
|
||||
"fe-auth-logout"
|
||||
);
|
||||
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||
await fetch("/api/auth/logout", init);
|
||||
setUser(null);
|
||||
log.info("[Auth] Logout completed", { correlationId });
|
||||
}
|
||||
|
||||
async function updateProfile(data: { currentPassword?: string; newPassword?: string }) {
|
||||
@@ -236,8 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
||||
throw new Error(err.error || "Upload failed");
|
||||
let code = "UNKNOWN";
|
||||
try {
|
||||
const body = (await res.json()) as { code?: string };
|
||||
if (typeof body?.code === "string" && body.code.trim().length > 0) {
|
||||
code = body.code;
|
||||
}
|
||||
} catch {
|
||||
// No JSON body
|
||||
}
|
||||
throw new Error(code);
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
@@ -574,34 +622,32 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [avatarError, setAvatarError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setAvatarError(t("form.imageUploadErrors.tooLarge"));
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
@@ -610,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
async function handleAvatarDelete() {
|
||||
setAvatarLoading(true);
|
||||
setError("");
|
||||
setAvatarError("");
|
||||
try {
|
||||
await deleteAvatar();
|
||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
||||
setAvatarError("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||
setAvatarError(resolveImageUploadError(code, t));
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
@@ -710,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="profile-username">{user.username}</span>
|
||||
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdate} className="profile-form">
|
||||
@@ -756,7 +804,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
{t("common.close", "Close")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -27,29 +28,22 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
useEscapeKey(true, onCancel);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
className="modal-content confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -10,6 +12,9 @@ interface ExportModalProps {
|
||||
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useScrollLock(isOpen);
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -17,13 +22,15 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
@@ -64,7 +71,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
</div>
|
||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("exportImport.cancelButton")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
interface FormNumberStepperProps {
|
||||
value: string;
|
||||
onChange: (nextValue: string) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
allowDecimal?: boolean;
|
||||
decrementLabel: string;
|
||||
incrementLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DECIMAL_ROUNDING_FACTOR = 1000;
|
||||
|
||||
function clamp(value: number, min: number, max?: number): number {
|
||||
const clampedMin = Math.max(min, value);
|
||||
if (max == null) return clampedMin;
|
||||
return Math.min(max, clampedMin);
|
||||
}
|
||||
|
||||
function normalizeDecimal(value: number): number {
|
||||
return Math.round(value * DECIMAL_ROUNDING_FACTOR) / DECIMAL_ROUNDING_FACTOR;
|
||||
}
|
||||
|
||||
function toDisplayValue(value: number, allowDecimal: boolean): string {
|
||||
if (!allowDecimal) return String(Math.max(0, Math.trunc(value)));
|
||||
const normalized = normalizeDecimal(value);
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
function sanitizeRawInput(raw: string, allowDecimal: boolean): string {
|
||||
const normalizedRaw = raw.replace(",", ".");
|
||||
if (allowDecimal) {
|
||||
const cleaned = normalizedRaw.replace(/[^\d.]/g, "");
|
||||
const [integerPart = "", ...fractionalParts] = cleaned.split(".");
|
||||
if (fractionalParts.length === 0) return integerPart;
|
||||
return `${integerPart}.${fractionalParts.join("")}`;
|
||||
}
|
||||
return normalizedRaw.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function parseInputValue(raw: string, allowDecimal: boolean): number | null {
|
||||
if (raw.trim() === "") return null;
|
||||
const parsed = allowDecimal ? Number.parseFloat(raw) : Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function FormNumberStepper({
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
allowDecimal = false,
|
||||
decrementLabel,
|
||||
incrementLabel,
|
||||
className = "",
|
||||
}: FormNumberStepperProps) {
|
||||
const parsed = parseInputValue(value, allowDecimal);
|
||||
const baseValue = parsed ?? min;
|
||||
const canDecrement = baseValue > min;
|
||||
const canIncrement = max == null || baseValue < max;
|
||||
|
||||
const normalizedClassName = ["number-stepper", "form-number-stepper", className].filter(Boolean).join(" ");
|
||||
|
||||
const handleStep = (direction: -1 | 1) => {
|
||||
const nextRaw = clamp(baseValue + direction * step, min, max);
|
||||
onChange(toDisplayValue(nextRaw, allowDecimal));
|
||||
};
|
||||
|
||||
const handleInputChange = (nextRaw: string) => {
|
||||
onChange(sanitizeRawInput(nextRaw, allowDecimal));
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const nextParsed = parseInputValue(value, allowDecimal);
|
||||
if (nextParsed == null) return;
|
||||
const clamped = clamp(nextParsed, min, max);
|
||||
onChange(toDisplayValue(clamped, allowDecimal));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={normalizedClassName}>
|
||||
{/* Input first in DOM so <label> associates with it, not the decrement button.
|
||||
CSS order restores the visual layout: [−] [input] [+]. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode={allowDecimal ? "decimal" : "numeric"}
|
||||
pattern={allowDecimal ? "[0-9]*\\.?[0-9]*" : "[0-9]*"}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => handleStep(-1)}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
onClick={() => handleStep(1)}
|
||||
disabled={!canIncrement}
|
||||
aria-label={incrementLabel}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
@@ -12,16 +12,7 @@ export interface LightboxProps {
|
||||
}
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
useEscapeKey(true, onClose);
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
@@ -31,7 +22,13 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<div
|
||||
className="lightbox-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="lightbox-container">
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
@@ -41,7 +38,9 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
alt={alt}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,40 +6,24 @@
|
||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||
*/
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses label-styled wrappers with custom interactive rows */
|
||||
/* biome-ignore-all lint/style/noNestedTernary: stock/preview rendering keeps explicit branch mapping */
|
||||
|
||||
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lightbox, MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
// =============================================================================
|
||||
// Local Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate blister stock - divides current pills into full blisters and partial
|
||||
*/
|
||||
function getBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
originalLooseTablets: number,
|
||||
_originalTotalPills: number
|
||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
||||
}
|
||||
const safeCurrent = Math.max(0, currentPills);
|
||||
const loosePills = Math.min(safeCurrent, Math.max(0, originalLooseTablets));
|
||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
||||
const fullBlisters = Math.floor(sealedPills / pillsPerBlister);
|
||||
const openBlisterPills = sealedPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full blisters column
|
||||
*/
|
||||
@@ -172,21 +156,11 @@ export function MedDetailModal({
|
||||
}
|
||||
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditStockModal) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
if (typeof event.stopImmediatePropagation === "function") {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
event.preventDefault();
|
||||
onCloseEditStockModal();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape, true);
|
||||
return () => document.removeEventListener("keydown", handleEscape, true);
|
||||
}, [showEditStockModal, onCloseEditStockModal]);
|
||||
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
||||
// Lightbox has its own useEscapeKey internally.
|
||||
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
||||
useEscapeKey(showEditStockModal, onCloseEditStockModal);
|
||||
useEscapeKey(showRefillModal, onCloseRefillModal);
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditStockModal) return;
|
||||
@@ -230,14 +204,12 @@ export function MedDetailModal({
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
||||
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
||||
const stock = splitCurrentBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets);
|
||||
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||
const pillsPerPack = Math.max(1, selectedMed.blistersPerPack * selectedMed.pillsPerBlister);
|
||||
const remainingPacks = Math.max(0, Math.ceil(Math.max(0, currentStock) / pillsPerPack));
|
||||
const stockDisplayTotal =
|
||||
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, currentStock);
|
||||
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, structuralMax);
|
||||
const maxPartialPills = Math.min(
|
||||
Math.max(0, selectedMed.pillsPerBlister),
|
||||
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
||||
@@ -294,6 +266,14 @@ export function MedDetailModal({
|
||||
|
||||
return (
|
||||
<div className="number-stepper refill-number-stepper">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
@@ -303,14 +283,6 @@ export function MedDetailModal({
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
@@ -340,16 +312,7 @@ export function MedDetailModal({
|
||||
const canIncrement = clamped < max;
|
||||
|
||||
return (
|
||||
<div className="number-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => onChange(Math.max(min, clamped - 1))}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="number-stepper refill-number-stepper">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
@@ -360,6 +323,15 @@ export function MedDetailModal({
|
||||
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => onChange(Math.max(min, clamped - 1))}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
@@ -388,21 +360,15 @@ export function MedDetailModal({
|
||||
onCloseEditStockModal();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onCloseEditStockModal();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content edit-stock-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseEditStockModal();
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -495,7 +461,7 @@ export function MedDetailModal({
|
||||
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||
setEditStockFullInput(raw);
|
||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||
onEditStockFullBlistersChange(normalized.full);
|
||||
@@ -524,7 +490,7 @@ export function MedDetailModal({
|
||||
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
|
||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||
onEditStockFullBlistersChange(normalized.full);
|
||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||
@@ -581,7 +547,7 @@ export function MedDetailModal({
|
||||
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
|
||||
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
|
||||
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||
const rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
||||
const _rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
||||
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
|
||||
onEditStockFullBlistersChange(normalized.full);
|
||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||
@@ -647,7 +613,7 @@ export function MedDetailModal({
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseEditStockModal}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
|
||||
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
||||
@@ -667,8 +633,7 @@ export function MedDetailModal({
|
||||
className="modal-overlay med-detail-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (showEditStockModal) return;
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -676,14 +641,9 @@ export function MedDetailModal({
|
||||
ref={detailModalRef}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -717,8 +677,8 @@ export function MedDetailModal({
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")}{" "}
|
||||
{selectedMed.takenBy.map((person, index) => (
|
||||
<span key={person}>
|
||||
{index > 0 && ", "}
|
||||
<span key={person} style={{ whiteSpace: "nowrap" }}>
|
||||
{index > 0 && (index === selectedMed.takenBy.length - 1 ? ` ${t("common.and")} ` : ", ")}
|
||||
{person}
|
||||
{selectedMed.intakes?.some(
|
||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||
@@ -782,7 +742,7 @@ export function MedDetailModal({
|
||||
<>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||
<span className="med-detail-value">{remainingPacks}</span>
|
||||
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||
</div>
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||
@@ -824,6 +784,72 @@ export function MedDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intake Schedule Section */}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{t("modal.intakeSchedule")}{" "}
|
||||
{selectedMed.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="med-detail-schedules">
|
||||
{(selectedMed.intakes && selectedMed.intakes.length > 0
|
||||
? selectedMed.intakes
|
||||
: selectedMed.blisters.map((blister) => ({
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
||||
}))
|
||||
).map((intake, idx) => {
|
||||
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 ?? selectedMed.intakeRemindersEnabled ?? false;
|
||||
|
||||
return (
|
||||
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{selectedMed.pillWeightMg &&
|
||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||
</span>
|
||||
{hasPerIntakeTakenBy && (
|
||||
<span className="med-schedule-person">
|
||||
{intake.takenBy}
|
||||
{showIntakeBell && (
|
||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||
<Bell size={13} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!hasPerIntakeTakenBy && showIntakeBell && (
|
||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||
<Bell size={13} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prescription Details Section */}
|
||||
{selectedMed.prescriptionEnabled && (
|
||||
<div className="med-detail-section">
|
||||
@@ -860,50 +886,6 @@ export function MedDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intake Schedule Section */}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{t("modal.intakeSchedule")}{" "}
|
||||
{selectedMed.intakeRemindersEnabled && (
|
||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="med-detail-schedules">
|
||||
{selectedMed.blisters.map((blister, idx) => {
|
||||
// When using new intakes format with per-intake takenBy,
|
||||
// each intake already represents one person's dose — don't multiply.
|
||||
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
||||
const intake = selectedMed.intakes?.[idx];
|
||||
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
||||
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
||||
const totalUsage = blister.usage * personCount;
|
||||
return (
|
||||
<div key={idx} className="med-schedule-item">
|
||||
<span className="med-schedule-usage">
|
||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{selectedMed.pillWeightMg &&
|
||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
||||
</span>
|
||||
<span className="med-schedule-time">
|
||||
{t("modal.at")}{" "}
|
||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Status Section */}
|
||||
{medCoverage && status && (
|
||||
<div className="med-detail-section">
|
||||
@@ -930,10 +912,10 @@ export function MedDetailModal({
|
||||
{selectedMed.notes && (
|
||||
<div className="med-detail-section">
|
||||
<h3>
|
||||
{t("modal.notes")}{" "}
|
||||
<span className="notes-icon notes-icon-static" aria-hidden="true">
|
||||
<NotebookPen size={14} />
|
||||
</span>{" "}
|
||||
{t("modal.notes")}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="med-notes-content">{selectedMed.notes}</div>
|
||||
</div>
|
||||
@@ -990,44 +972,45 @@ export function MedDetailModal({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Footer */}
|
||||
<div className="med-detail-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
<div className="footer-actions">
|
||||
<button className="success" onClick={onOpenRefillModal}>
|
||||
{t("refill.button")}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="med-detail-footer">
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
<div className="footer-actions">
|
||||
<button className="success" onClick={onOpenRefillModal}>
|
||||
{t("refill.button")}
|
||||
</button>
|
||||
{onOpenMedicationEdit && (
|
||||
<button
|
||||
className="info icon-only tooltip-trigger"
|
||||
onClick={onOpenMedicationEdit}
|
||||
aria-label={t("common.edit")}
|
||||
data-tooltip={t("common.edit")}
|
||||
>
|
||||
<Pencil size={18} aria-hidden="true" />
|
||||
</button>
|
||||
{onOpenMedicationEdit && (
|
||||
<button
|
||||
className="info icon-only tooltip-trigger"
|
||||
onClick={onOpenMedicationEdit}
|
||||
aria-label={t("common.edit")}
|
||||
data-tooltip={t("common.edit")}
|
||||
>
|
||||
<Pencil size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{onOpenEditStockModal && (
|
||||
<button
|
||||
className="icon-stock-correction icon-only tooltip-trigger"
|
||||
onClick={onOpenEditStockModal}
|
||||
aria-label={t("editStock.buttonLabel")}
|
||||
data-tooltip={t("editStock.buttonLabel")}
|
||||
>
|
||||
<FilePenLine size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<button
|
||||
className="secondary icon-only tooltip-trigger"
|
||||
onClick={() => generateICS(selectedMed)}
|
||||
aria-label={t("modal.exportTooltip")}
|
||||
data-tooltip={t("modal.exportTooltip")}
|
||||
>
|
||||
<Calendar size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onOpenEditStockModal && (
|
||||
<button
|
||||
className="icon-stock-correction icon-only tooltip-trigger"
|
||||
onClick={onOpenEditStockModal}
|
||||
aria-label={t("editStock.buttonLabel")}
|
||||
data-tooltip={t("editStock.buttonLabel")}
|
||||
>
|
||||
<FilePenLine size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{selectedMed.blisters.length > 0 && (
|
||||
<button
|
||||
className="secondary icon-only tooltip-trigger"
|
||||
onClick={() => generateICS(selectedMed)}
|
||||
aria-label={t("modal.exportTooltip")}
|
||||
data-tooltip={t("modal.exportTooltip")}
|
||||
>
|
||||
<Calendar size={18} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1046,14 +1029,15 @@ export function MedDetailModal({
|
||||
onCloseRefillModal();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onCloseRefillModal();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content refill-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1131,7 +1115,7 @@ export function MedDetailModal({
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="ghost" onClick={onCloseRefillModal}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<div className="refill-footer-right">
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// MedicationAvatar Component
|
||||
// =============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type MedicationAvatarProps = {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
@@ -9,6 +11,12 @@ export type MedicationAvatarProps = {
|
||||
};
|
||||
|
||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbFailed(false);
|
||||
}, [imageUrl]);
|
||||
|
||||
const initials =
|
||||
name
|
||||
.split(" ")
|
||||
@@ -19,7 +27,26 @@ export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvat
|
||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||
|
||||
if (imageUrl) {
|
||||
return <img src={`/api/images/${imageUrl}`} alt={name} className={sizeClass} />;
|
||||
const normalizedImageUrl = imageUrl.toLowerCase();
|
||||
const shouldUseThumbFirst = normalizedImageUrl.endsWith(".webp");
|
||||
const extIndex = imageUrl.lastIndexOf(".");
|
||||
const baseName = extIndex > 0 ? imageUrl.slice(0, extIndex) : imageUrl;
|
||||
const thumbSrc = `/api/images/${baseName}-thumb.webp`;
|
||||
const fullSrc = `/api/images/${imageUrl}`;
|
||||
const resolvedSrc = shouldUseThumbFirst && !thumbFailed ? thumbSrc : fullSrc;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={name}
|
||||
className={sizeClass}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
if (shouldUseThumbFirst && !thumbFailed) setThumbFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { UserProfile } from "./Auth";
|
||||
|
||||
interface ProfileModalProps {
|
||||
@@ -6,6 +7,8 @@ interface ProfileModalProps {
|
||||
}
|
||||
|
||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -13,13 +16,15 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content profile-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useScrollLock } from "../hooks/useScrollLock";
|
||||
import type { Medication } from "../types";
|
||||
import { getPackageSize } from "../types";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
@@ -16,6 +18,7 @@ type ReportData = Record<
|
||||
number,
|
||||
{
|
||||
dosesTaken: number;
|
||||
automaticDosesTaken: number;
|
||||
dosesDismissed: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
@@ -30,6 +33,9 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
||||
|
||||
useScrollLock(isOpen);
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Collect all unique "taken by" people across all medications
|
||||
const allPeople = useMemo(() => {
|
||||
const people = new Set<string>();
|
||||
@@ -137,13 +143,15 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content report-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
@@ -256,7 +264,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
{/* Actions */}
|
||||
<div className="report-actions">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -382,6 +390,9 @@ function generateTextReport(
|
||||
lines.push(h3(t("report.docIntakeHistory")));
|
||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
||||
if (data.automaticDosesTaken > 0) {
|
||||
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)));
|
||||
@@ -580,6 +591,9 @@ function buildPrintHtml(
|
||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||
s += `<table><tbody>`;
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||
if (data.automaticDosesTaken > 0) {
|
||||
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||
}
|
||||
if (data.dosesDismissed > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||
if (data.firstDoseAt)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
show: boolean;
|
||||
@@ -43,6 +44,8 @@ export function ShareDialog({
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
|
||||
useEscapeKey(show, onClose);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
@@ -50,13 +53,15 @@ export function ShareDialog({
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content share-dialog-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -124,8 +129,12 @@ export function ShareDialog({
|
||||
return (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPerson")}</label>
|
||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
||||
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||
<select
|
||||
id="share-person-select"
|
||||
value={shareSelectedPerson}
|
||||
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||
>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
@@ -135,8 +144,12 @@ export function ShareDialog({
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPeriod")}</label>
|
||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
||||
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||
<select
|
||||
id="share-period-select"
|
||||
value={shareSelectedDays}
|
||||
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||
>
|
||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||
@@ -145,7 +158,7 @@ export function ShareDialog({
|
||||
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// =============================================================================
|
||||
// SharedSchedule Component - Public view for shared schedules
|
||||
// =============================================================================
|
||||
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
|
||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
@@ -149,15 +152,7 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
// Close lightbox on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && lightboxImage) {
|
||||
closeLightbox();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [lightboxImage, closeLightbox]);
|
||||
useEscapeKey(!!lightboxImage, closeLightbox);
|
||||
|
||||
// Handle browser back button to close lightbox
|
||||
useEffect(() => {
|
||||
@@ -1281,7 +1276,7 @@ export function SharedSchedule() {
|
||||
className="lightbox-overlay"
|
||||
onClick={closeLightbox}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") closeLightbox();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
@@ -1292,7 +1287,9 @@ export function SharedSchedule() {
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
@@ -31,6 +32,8 @@ export function UserFilterModal({
|
||||
}: UserFilterModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEscapeKey(!!selectedUser, onClose);
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
@@ -40,13 +43,15 @@ export function UserFilterModal({
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content user-meds-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
@@ -98,13 +103,14 @@ export function UserFilterModal({
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
{personIntakes.length > 0 && (
|
||||
<div className="user-med-intakes">
|
||||
{personIntakes.map((intake, idx) => {
|
||||
{personIntakes.map((intake) => {
|
||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||
return (
|
||||
<span key={idx} className="user-med-intake-item">
|
||||
<span key={intakeKey} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
|
||||
@@ -6,6 +6,7 @@ export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export { FormNumberStepper } from "./FormNumberStepper";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type React from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
||||
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
||||
@@ -72,6 +72,7 @@ export interface AppContextValue {
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
@@ -127,7 +128,7 @@ export interface AppContextValue {
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
editingId: number | null,
|
||||
setForm: React.Dispatch<React.SetStateAction<any>>,
|
||||
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||
loadMeds: () => void,
|
||||
usePrescription?: boolean
|
||||
) => Promise<void>;
|
||||
@@ -212,7 +213,17 @@ export interface AppContextValue {
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
|
||||
|
||||
const AppContext = (() => {
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
|
||||
};
|
||||
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
|
||||
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
|
||||
}
|
||||
return globalRef[APP_CONTEXT_SINGLETON_KEY];
|
||||
})();
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -242,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Modal state
|
||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||
const selectedMedIdRef = useRef<number | null>(null);
|
||||
const medDetailOpenedAtRef = useRef(0);
|
||||
const medDetailCloseInFlightRef = useRef(false);
|
||||
useEffect(() => {
|
||||
selectedMedIdRef.current = selectedMed?.id ?? null;
|
||||
if (!selectedMed) {
|
||||
medDetailCloseInFlightRef.current = false;
|
||||
}
|
||||
}, [selectedMed]);
|
||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||
const imageLightboxOpenedAtRef = useRef(0);
|
||||
const imageLightboxCloseInFlightRef = useRef(false);
|
||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||
const scheduleLightboxOpenedAtRef = useRef(0);
|
||||
const scheduleLightboxCloseInFlightRef = useRef(false);
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!showImageLightbox) {
|
||||
imageLightboxCloseInFlightRef.current = false;
|
||||
}
|
||||
}, [showImageLightbox]);
|
||||
useEffect(() => {
|
||||
if (!scheduleLightboxImage) {
|
||||
scheduleLightboxCloseInFlightRef.current = false;
|
||||
}
|
||||
}, [scheduleLightboxImage]);
|
||||
|
||||
// Export/Import state
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -456,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
// Modal helpers with browser history support
|
||||
const openMedDetail = useCallback(
|
||||
(med: Medication) => {
|
||||
if (selectedMedIdRef.current === med.id) return;
|
||||
selectedMedIdRef.current = med.id;
|
||||
medDetailOpenedAtRef.current = Date.now();
|
||||
medDetailCloseInFlightRef.current = false;
|
||||
setSelectedMed(med);
|
||||
refill.setRefillHistoryExpanded(false);
|
||||
refill.loadRefillHistory(med.id);
|
||||
@@ -465,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
const closeMedDetail = useCallback(() => {
|
||||
if (selectedMed) {
|
||||
window.history.back();
|
||||
if (!selectedMed || medDetailCloseInFlightRef.current) return;
|
||||
|
||||
// Ignore ultra-fast close requests caused by rapid double-click races
|
||||
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
|
||||
|
||||
const currentState = window.history.state as { modal?: string } | null;
|
||||
if (currentState?.modal !== "medDetail") {
|
||||
// State already popped by another event: close locally without another back step.
|
||||
selectedMedIdRef.current = null;
|
||||
setSelectedMed(null);
|
||||
return;
|
||||
}
|
||||
|
||||
medDetailCloseInFlightRef.current = true;
|
||||
window.history.back();
|
||||
}, [selectedMed]);
|
||||
|
||||
const openImageLightbox = useCallback(() => {
|
||||
if (showImageLightbox) return;
|
||||
imageLightboxOpenedAtRef.current = Date.now();
|
||||
imageLightboxCloseInFlightRef.current = false;
|
||||
setShowImageLightbox(true);
|
||||
window.history.pushState({ modal: "imageLightbox" }, "");
|
||||
}, []);
|
||||
|
||||
const closeImageLightbox = useCallback(() => {
|
||||
if (showImageLightbox) {
|
||||
window.history.back();
|
||||
}
|
||||
}, [showImageLightbox]);
|
||||
|
||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
||||
setScheduleLightboxImage(imageUrl);
|
||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||
}, []);
|
||||
const closeImageLightbox = useCallback(() => {
|
||||
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
|
||||
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
|
||||
|
||||
const currentState = window.history.state as { modal?: string } | null;
|
||||
if (currentState?.modal !== "imageLightbox") {
|
||||
setShowImageLightbox(false);
|
||||
return;
|
||||
}
|
||||
|
||||
imageLightboxCloseInFlightRef.current = true;
|
||||
window.history.back();
|
||||
}, [showImageLightbox]);
|
||||
|
||||
const openScheduleLightbox = useCallback(
|
||||
(imageUrl: string) => {
|
||||
if (scheduleLightboxImage) return;
|
||||
scheduleLightboxOpenedAtRef.current = Date.now();
|
||||
scheduleLightboxCloseInFlightRef.current = false;
|
||||
setScheduleLightboxImage(imageUrl);
|
||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||
},
|
||||
[scheduleLightboxImage]
|
||||
);
|
||||
|
||||
const closeScheduleLightbox = useCallback(() => {
|
||||
if (scheduleLightboxImage) {
|
||||
window.history.back();
|
||||
if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
|
||||
if (Date.now() - scheduleLightboxOpenedAtRef.current < 320) return;
|
||||
|
||||
const currentState = window.history.state as { modal?: string } | null;
|
||||
if (currentState?.modal !== "scheduleLightbox") {
|
||||
setScheduleLightboxImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleLightboxCloseInFlightRef.current = true;
|
||||
window.history.back();
|
||||
}, [scheduleLightboxImage]);
|
||||
|
||||
const openUserFilter = useCallback((person: string) => {
|
||||
setSelectedUser(person);
|
||||
window.history.pushState({ modal: "userFilter", person }, "");
|
||||
}, []);
|
||||
const openUserFilter = useCallback(
|
||||
(person: string) => {
|
||||
if (selectedUser === person) return;
|
||||
setSelectedUser(person);
|
||||
window.history.pushState({ modal: "userFilter", person }, "");
|
||||
},
|
||||
[selectedUser]
|
||||
);
|
||||
|
||||
const closeUserFilter = useCallback(() => {
|
||||
if (selectedUser) {
|
||||
@@ -732,6 +811,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||
getDoseId: doses.getDoseId,
|
||||
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||
countTakenDoses: doses.countTakenDoses,
|
||||
markDoseTaken: doses.markDoseTaken,
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
|
||||
@@ -4,12 +4,15 @@ export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
||||
export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useDoses } from "./useDoses";
|
||||
export { useEscapeKey } from "./useEscapeKey";
|
||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedications } from "./useMedications";
|
||||
export { useModalHistory } from "./useModalHistory";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
export { useRefill } from "./useRefill";
|
||||
export { useScrollLock } from "./useScrollLock";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
export { useSettings } from "./useSettings";
|
||||
export type { UseShareReturn } from "./useShare";
|
||||
|
||||
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
takenDoseTimestamps: Map<string, number>;
|
||||
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||
dismissedDoses: Set<string>;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
|
||||
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
|
||||
const data = await res.json();
|
||||
const taken = new Set<string>();
|
||||
const timestamps = new Map<string, number>();
|
||||
const sources = new Map<string, "manual" | "automatic">();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses) {
|
||||
if (d.dismissed) {
|
||||
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
timestamps.set(d.doseId, d.takenAt);
|
||||
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setTakenDoseTimestamps(timestamps);
|
||||
setTakenDoseSources(sources);
|
||||
setDismissedDoses(dismissed);
|
||||
}
|
||||
// Don't reset on error - keep current state
|
||||
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
|
||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
}, []);
|
||||
|
||||
const isDoseTakenAutomatically = useCallback(
|
||||
(doseId: string): boolean => {
|
||||
return takenDoseSources.get(doseId) === "automatic";
|
||||
},
|
||||
[takenDoseSources]
|
||||
);
|
||||
|
||||
// Count taken doses for a day/item
|
||||
const countTakenDoses = useCallback(
|
||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
|
||||
next.set(doseId, Date.now());
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(doseId, "manual");
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
// Re-sync with server after mutation completes
|
||||
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
|
||||
takenDoses,
|
||||
setTakenDoses,
|
||||
takenDoseTimestamps,
|
||||
takenDoseSources,
|
||||
dismissedDoses,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Close a modal/overlay when the user presses Escape.
|
||||
*
|
||||
* Registers a document-level `keydown` listener so it works regardless
|
||||
* of which element has focus. Every modal **must** use this hook —
|
||||
* relying on `onKeyDown` on overlay divs is unreliable because those
|
||||
* handlers only fire when the overlay itself (or a descendant) has focus.
|
||||
*
|
||||
* @param active – whether the modal is currently open
|
||||
* @param onClose – callback to close the modal
|
||||
* @param options.capture – use capture phase (default: false).
|
||||
* Set to `true` for nested sub-modals that must intercept Escape
|
||||
* before a parent's handler fires.
|
||||
*/
|
||||
export function useEscapeKey(active: boolean, onClose: () => void, options?: { capture?: boolean }): void {
|
||||
const capture = options?.capture ?? false;
|
||||
const activeRef = useRef(active);
|
||||
const onCloseRef = useRef(onClose);
|
||||
|
||||
// Keep refs in sync without re-registering the listener
|
||||
activeRef.current = active;
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && activeRef.current) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown, capture);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, capture);
|
||||
}, [active, capture]);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
if (error) errors[f] = error;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}, [form.name, form.genericName, form.notes, validateField]);
|
||||
}, [form.name, form.genericName, form.notes, validateField, form]);
|
||||
|
||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||
setForm((prev) => {
|
||||
|
||||
@@ -50,13 +50,33 @@ export function useMedications(): UseMedicationsReturn {
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) {
|
||||
loadMeds();
|
||||
if (!res.ok) {
|
||||
let code = "UNKNOWN";
|
||||
try {
|
||||
const errorBody = (await res.json()) as { code?: string };
|
||||
if (typeof errorBody?.code === "string" && errorBody.code.trim().length > 0) {
|
||||
code = errorBody.code;
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback code when backend response has no JSON body.
|
||||
}
|
||||
throw new Error(code);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
loadMeds();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Network failures (fetch itself throws) produce browser-specific messages.
|
||||
// Normalise to NETWORK_ERROR code so the UI can map to a translated string.
|
||||
if (error.message === "Failed to fetch" || error.message.startsWith("NetworkError")) {
|
||||
throw new Error("NETWORK_ERROR");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("UNKNOWN");
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
setUploadingImage(false);
|
||||
},
|
||||
[loadMeds]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Push a history entry when a modal opens so the browser back button closes it.
|
||||
* On popstate (back), calls `onClose` to dismiss the modal.
|
||||
*/
|
||||
export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () => void) {
|
||||
const pushedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.history.pushState({ modal: modalKey }, "");
|
||||
pushedRef.current = true;
|
||||
} else if (pushedRef.current) {
|
||||
pushedRef.current = false;
|
||||
}
|
||||
}, [isOpen, modalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePopState = () => {
|
||||
if (pushedRef.current) {
|
||||
pushedRef.current = false;
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [isOpen, onClose]);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Lock background scrolling when a modal/overlay is visible.
|
||||
*
|
||||
* Uses the `position: fixed` technique to prevent scroll on iOS Safari
|
||||
* and other browsers where `overflow: hidden` alone is insufficient.
|
||||
* Saves and restores the scroll position on cleanup so users don't
|
||||
* lose their place.
|
||||
*
|
||||
* Supports nesting: a scroll-lock counter prevents premature unlock
|
||||
* when multiple modals stack (e.g. MedDetail → RefillModal).
|
||||
*/
|
||||
|
||||
let lockCount = 0;
|
||||
let savedScrollY = 0;
|
||||
|
||||
export function useScrollLock(active: boolean): void {
|
||||
const wasActive = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !wasActive.current) {
|
||||
wasActive.current = true;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
if (lockCount === 0) {
|
||||
savedScrollY = window.scrollY;
|
||||
html.classList.add("modal-open");
|
||||
html.style.overflow = "hidden";
|
||||
html.style.overscrollBehavior = "none";
|
||||
body.classList.add("modal-open");
|
||||
body.style.overflow = "hidden";
|
||||
body.style.position = "fixed";
|
||||
body.style.top = `-${savedScrollY}px`;
|
||||
body.style.left = "0";
|
||||
body.style.right = "0";
|
||||
body.style.width = "100%";
|
||||
body.style.overscrollBehavior = "none";
|
||||
}
|
||||
lockCount++;
|
||||
}
|
||||
|
||||
if (!active && wasActive.current) {
|
||||
wasActive.current = false;
|
||||
lockCount--;
|
||||
if (lockCount <= 0) {
|
||||
lockCount = 0;
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wasActive.current) {
|
||||
wasActive.current = false;
|
||||
lockCount--;
|
||||
if (lockCount <= 0) {
|
||||
lockCount = 0;
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
function unlock(): void {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
html.classList.remove("modal-open");
|
||||
html.style.overflow = "";
|
||||
html.style.overscrollBehavior = "";
|
||||
body.classList.remove("modal-open");
|
||||
body.style.overflow = "";
|
||||
body.style.position = "";
|
||||
body.style.top = "";
|
||||
body.style.left = "";
|
||||
body.style.right = "";
|
||||
body.style.width = "";
|
||||
body.style.overscrollBehavior = "";
|
||||
window.scrollTo(0, savedScrollY);
|
||||
}
|
||||
@@ -249,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settingsToSave,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
};
|
||||
const updatedSettings = { ...settingsToSave };
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(updatedSettings);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Medication } from "../types";
|
||||
import { withCorrelation } from "../utils/correlation";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
export interface UseShareReturn {
|
||||
showShareDialog: boolean;
|
||||
@@ -45,36 +47,57 @@ export function useShare(): UseShareReturn {
|
||||
const allPeople = meds.flatMap((m) => m.takenBy || []);
|
||||
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
||||
setSharePeople(uniquePeople);
|
||||
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
|
||||
if (uniquePeople.length > 0) {
|
||||
setShareSelectedPerson(uniquePeople[0]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const generateShareLink = useCallback(async () => {
|
||||
if (!shareSelectedPerson) return;
|
||||
if (!shareSelectedPerson) {
|
||||
log.warn("[ShareDialog] Attempted to generate link without selected person");
|
||||
return;
|
||||
}
|
||||
setShareGenerating(true);
|
||||
setShareCopied(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/share", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays,
|
||||
}),
|
||||
});
|
||||
const { correlationId, init } = withCorrelation(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays,
|
||||
}),
|
||||
},
|
||||
"fe-share"
|
||||
);
|
||||
const res = await fetch("/api/share", init);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||
setShareLink(fullUrl);
|
||||
log.info("[ShareDialog] Share link ready", {
|
||||
person: shareSelectedPerson,
|
||||
days: shareSelectedDays,
|
||||
reused: Boolean(data.reused),
|
||||
correlationId,
|
||||
});
|
||||
} else {
|
||||
const err = await res.json();
|
||||
log.error("[ShareDialog] Failed to generate share link", {
|
||||
status: res.status,
|
||||
person: shareSelectedPerson,
|
||||
error: err.error,
|
||||
correlationId,
|
||||
});
|
||||
alert(err.error || "Failed to generate share link");
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
|
||||
alert("Failed to generate share link");
|
||||
} finally {
|
||||
setShareGenerating(false);
|
||||
@@ -83,20 +106,53 @@ export function useShare(): UseShareReturn {
|
||||
|
||||
const copyShareLink = useCallback(() => {
|
||||
if (shareLink) {
|
||||
navigator.clipboard.writeText(shareLink);
|
||||
setShareCopied(true);
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(shareLink).then(
|
||||
() => {
|
||||
setShareCopied(true);
|
||||
log.debug("[ShareDialog] Share link copied to clipboard");
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
},
|
||||
() => {
|
||||
// Clipboard API blocked (non-secure context / permissions)
|
||||
fallbackCopyToClipboard(shareLink);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
fallbackCopyToClipboard(shareLink);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopyToClipboard(text: string) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
setShareCopied(true);
|
||||
log.debug("[ShareDialog] Share link copied via fallback");
|
||||
setTimeout(() => setShareCopied(false), 2000);
|
||||
} catch {
|
||||
log.warn("[ShareDialog] Clipboard copy failed — not in secure context");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const closeShareDialog = useCallback(() => {
|
||||
if (showShareDialog) {
|
||||
log.debug("[ShareDialog] Closing dialog");
|
||||
window.history.back();
|
||||
}
|
||||
}, [showShareDialog]);
|
||||
|
||||
// Internal function to reset share dialog state (called by popstate handler)
|
||||
const resetShareDialogState = useCallback(() => {
|
||||
log.debug("[ShareDialog] Reset dialog state");
|
||||
setShowShareDialog(false);
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Bearbeiten",
|
||||
"editEntryWithName": "Bearbeiten: {{name}}",
|
||||
"viewEntry": "Ansehen",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
@@ -182,6 +183,13 @@
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
"removeImage": "Bild entfernen",
|
||||
"imageUploadErrors": {
|
||||
"tooLarge": "Das Bild ist zu groß. Die maximale Upload-Größe beträgt 10 MB.",
|
||||
"invalidType": "Ungültiger Dateityp. Erlaubte Formate: JPEG, PNG, WebP, GIF.",
|
||||
"invalidImage": "Ungültige oder nicht unterstützte Bilddatei.",
|
||||
"noFile": "Es wurde keine Datei zum Hochladen ausgewählt.",
|
||||
"generic": "Bild-Upload fehlgeschlagen. Bitte versuche es erneut."
|
||||
},
|
||||
"placeholders": {
|
||||
"commercial": "z.B. Ozempic",
|
||||
"generic": "z.B. Semaglutid (optional)",
|
||||
@@ -350,7 +358,9 @@
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||
"automaticTaken": "Automatisch eingenommen",
|
||||
"hasNotes": "Hat Notizen",
|
||||
"hasPrescription": "Rezeptverfolgung aktiviert",
|
||||
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
||||
"lightMode": "Zum hellen Modus wechseln",
|
||||
"darkMode": "Zum dunklen Modus wechseln"
|
||||
@@ -450,6 +460,7 @@
|
||||
"loose": "lose",
|
||||
"none": "Kein",
|
||||
"daily": "täglich",
|
||||
"and": "und",
|
||||
"everyNDays": "alle {{count}} Tage",
|
||||
"day": "Tag",
|
||||
"days": "Tage",
|
||||
@@ -462,7 +473,9 @@
|
||||
"pillsTotal": "{{count}} Tabletten gesamt",
|
||||
"pillsTotal_one": "{{count}} Tablette gesamt",
|
||||
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
||||
"max": "max"
|
||||
"max": "max",
|
||||
"on": "An",
|
||||
"off": "Aus"
|
||||
},
|
||||
"share": {
|
||||
"button": "Teilen",
|
||||
@@ -645,6 +658,7 @@
|
||||
"docPrescriptionExpiry": "Rezeptablauf",
|
||||
"docIntakeHistory": "Einnahme-Verlauf",
|
||||
"docDosesTaken": "Eingenommene Dosen",
|
||||
"docDosesTakenAutomatic": "Automatisch eingenommen",
|
||||
"docDosesDismissed": "Verworfene Dosen",
|
||||
"docFirstDose": "Erste Dosis",
|
||||
"docLastDose": "Letzte Dosis",
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit",
|
||||
"editEntryWithName": "Edit: {{name}}",
|
||||
"viewEntry": "View",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
@@ -182,6 +183,13 @@
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
"removeImage": "Remove Image",
|
||||
"imageUploadErrors": {
|
||||
"tooLarge": "Image is too large. Maximum upload size is 10 MB.",
|
||||
"invalidType": "Invalid file type. Allowed formats: JPEG, PNG, WebP, GIF.",
|
||||
"invalidImage": "Invalid or unsupported image file.",
|
||||
"noFile": "No file was selected for upload.",
|
||||
"generic": "Image upload failed. Please try again."
|
||||
},
|
||||
"placeholders": {
|
||||
"commercial": "e.g. Ozempic",
|
||||
"generic": "e.g. Semaglutide (optional)",
|
||||
@@ -350,7 +358,9 @@
|
||||
},
|
||||
"tooltips": {
|
||||
"intakeReminders": "Intake reminders enabled",
|
||||
"automaticTaken": "Automatically taken",
|
||||
"hasNotes": "Has notes",
|
||||
"hasPrescription": "Prescription tracking enabled",
|
||||
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
||||
"lightMode": "Switch to light mode",
|
||||
"darkMode": "Switch to dark mode"
|
||||
@@ -450,6 +460,7 @@
|
||||
"loose": "loose",
|
||||
"none": "None",
|
||||
"daily": "daily",
|
||||
"and": "and",
|
||||
"everyNDays": "every {{count}} days",
|
||||
"day": "day",
|
||||
"days": "days",
|
||||
@@ -462,7 +473,9 @@
|
||||
"pillsTotal": "{{count}} pills total",
|
||||
"pillsTotal_one": "{{count}} pill total",
|
||||
"pillsTotal_other": "{{count}} pills total",
|
||||
"max": "max"
|
||||
"max": "max",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"share": {
|
||||
"button": "Share",
|
||||
@@ -645,6 +658,7 @@
|
||||
"docPrescriptionExpiry": "Prescription expiry",
|
||||
"docIntakeHistory": "Intake History",
|
||||
"docDosesTaken": "Doses taken",
|
||||
"docDosesTakenAutomatic": "Automatically taken",
|
||||
"docDosesDismissed": "Doses dismissed",
|
||||
"docFirstDose": "First dose",
|
||||
"docLastDose": "Last dose",
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
||||
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||
import { Bell, ClipboardList, NotebookPen, Share2 } 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 { useModalHistory } from "../hooks";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
// Helper function to calculate blister stock
|
||||
export function getBlisterStock(
|
||||
totalPills: number,
|
||||
pillsPerBlister: number,
|
||||
_looseTablets: number,
|
||||
_originalTotal: number
|
||||
) {
|
||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
||||
const openBlisterPills = totalPills % pillsPerBlister;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
}
|
||||
|
||||
// Helper to format full blisters
|
||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
// Helper to format open blister and loose pills
|
||||
export function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills === 0 && loosePills === 0) return "-";
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
|
||||
// Get total pills for a medication (packageType-aware)
|
||||
export function getMedTotal(med: {
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number | null;
|
||||
packageType?: string;
|
||||
}): number {
|
||||
if (med.packageType === "bottle") {
|
||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
}
|
||||
import {
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
getBlisterStock,
|
||||
getMedTotal,
|
||||
getReminderStatusData,
|
||||
userStorageKey,
|
||||
} from "./dashboard-helpers";
|
||||
|
||||
// Notification bell SVG icon (no emoji)
|
||||
function NotificationBellIcon() {
|
||||
@@ -76,108 +37,6 @@ function NotificationBellIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// Get structured reminder status data
|
||||
export function getReminderStatusData(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_allLowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
_lastNotificationType: string | null,
|
||||
_lastNotificationChannel: string | null,
|
||||
lastReminderMedName: string | null,
|
||||
lastReminderTakenBy: string | null,
|
||||
lastStockReminderSent: string | null,
|
||||
_lastStockReminderChannel: string | null,
|
||||
lastStockReminderMedNames: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): {
|
||||
status: { text: string; className: string };
|
||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||
lastStockSent: { date: string; medNames: string | null } | null;
|
||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.daysLeft === null) continue;
|
||||
|
||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||
const isLow = c.daysLeft < lowStockDays;
|
||||
if (!isCritical && !isLow) continue;
|
||||
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||
}
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
// Determine status
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (lowCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||
className: "warning",
|
||||
};
|
||||
} else {
|
||||
status = {
|
||||
text: t("dashboard.reminders.allOk"),
|
||||
className: "success",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse last stock reminder sent info (from dedicated stock tracking columns)
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
const sentDate = new Date(lastStockReminderSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastStockSent = {
|
||||
date: formattedDate,
|
||||
medNames: lastStockReminderMedNames,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse last intake reminder sent info (from intake tracking columns)
|
||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
const sentDate = new Date(lastAutoEmailSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastIntakeSent = {
|
||||
date: formattedDate,
|
||||
medName: lastReminderMedName,
|
||||
takenBy: lastReminderTakenBy,
|
||||
};
|
||||
}
|
||||
|
||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
@@ -206,6 +65,7 @@ export function DashboardPage() {
|
||||
missedPastDoseIds,
|
||||
getDayStockStatus,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
clearingMissed,
|
||||
@@ -218,6 +78,8 @@ export function DashboardPage() {
|
||||
loadSettings,
|
||||
} = useAppContext();
|
||||
|
||||
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
|
||||
|
||||
// Get structured reminder data
|
||||
const reminderData = getReminderStatusData(
|
||||
settings.reminderDaysBefore,
|
||||
@@ -650,7 +512,21 @@ export function DashboardPage() {
|
||||
>
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<span className="med-name-line">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span
|
||||
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
</span>
|
||||
<span className="med-name-block-dash">
|
||||
<span className="med-name-text">
|
||||
{row.name}
|
||||
@@ -662,6 +538,17 @@ export function DashboardPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{med?.prescriptionEnabled && (
|
||||
<>
|
||||
{" "}
|
||||
<span
|
||||
className="prescription-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.hasPrescription")}
|
||||
>
|
||||
<ClipboardList size={13} aria-hidden="true" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && (
|
||||
<span className="med-taken-by-line">
|
||||
@@ -868,7 +755,15 @@ export function DashboardPage() {
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<div className="med-name-stack">
|
||||
<div
|
||||
className="med-name-stack clickable"
|
||||
onClick={() => med && openMedDetail(med)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med) openMedDetail(med);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
@@ -907,6 +802,8 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
@@ -926,6 +823,14 @@ export function DashboardPage() {
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
@@ -1110,7 +1015,15 @@ export function DashboardPage() {
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<div className="med-name-stack">
|
||||
<div
|
||||
className="med-name-stack clickable"
|
||||
onClick={() => med && openMedDetail(med)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med) openMedDetail(med);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
@@ -1153,6 +1066,8 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
@@ -1172,6 +1087,14 @@ export function DashboardPage() {
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
@@ -1323,7 +1246,15 @@ export function DashboardPage() {
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<div className="med-name-stack">
|
||||
<div
|
||||
className="med-name-stack clickable"
|
||||
onClick={() => med && openMedDetail(med)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med) openMedDetail(med);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
@@ -1362,6 +1293,8 @@ export function DashboardPage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
@@ -1381,6 +1314,14 @@ export function DashboardPage() {
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
/* 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
||||
import {
|
||||
ConfirmModal,
|
||||
DateInput,
|
||||
FormNumberStepper,
|
||||
Lightbox,
|
||||
MedicationAvatar,
|
||||
MobileEditModal,
|
||||
ReportModal,
|
||||
} from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -31,7 +43,6 @@ export function MedicationsPage() {
|
||||
deleteMedImage,
|
||||
uploadingImage,
|
||||
existingPeople,
|
||||
coverage,
|
||||
coverageByMed,
|
||||
} = useAppContext();
|
||||
|
||||
@@ -41,6 +52,7 @@ export function MedicationsPage() {
|
||||
setForm,
|
||||
setOriginalForm,
|
||||
editingId,
|
||||
setEditingId,
|
||||
formSaved,
|
||||
setFormSaved,
|
||||
formChanged,
|
||||
@@ -66,12 +78,18 @@ export function MedicationsPage() {
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// View mode: grid (default) or form (edit/new)
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(pendingEditTransition && window.innerWidth <= 768);
|
||||
const showEditModalRef = useRef(false);
|
||||
useEffect(() => {
|
||||
showEditModalRef.current = showEditModal;
|
||||
}, [showEditModal]);
|
||||
const processedEditMedIdRef = useRef<string | null>(null);
|
||||
const hasDesktopFormHistoryState = useRef(false);
|
||||
|
||||
@@ -122,9 +140,36 @@ export function MedicationsPage() {
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
|
||||
const handlePendingMedicationImageSelection = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setImageUploadError(null);
|
||||
setPendingImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImageUploadError(null);
|
||||
}, [editingId]);
|
||||
const [showObsolete, setShowObsolete] = useState(true);
|
||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,6 +201,42 @@ export function MedicationsPage() {
|
||||
void loadAllMeds();
|
||||
}, [loadAllMeds]);
|
||||
|
||||
const tryUploadMedImage = useCallback(
|
||||
async (medId: number, file: File) => {
|
||||
setImageUploadError(null);
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await uploadMedImage(medId, file);
|
||||
void loadAllMeds();
|
||||
setImageUploadError(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error ? error.message : "UNKNOWN";
|
||||
setImageUploadError(resolveImageUploadError(code, t));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t, uploadMedImage, loadAllMeds]
|
||||
);
|
||||
|
||||
const handleUploadMedImage = useCallback(
|
||||
async (medId: number, file: File) => {
|
||||
await tryUploadMedImage(medId, file);
|
||||
},
|
||||
[tryUploadMedImage]
|
||||
);
|
||||
|
||||
const handleDeleteMedImage = useCallback(
|
||||
async (medId: number) => {
|
||||
await deleteMedImage(medId);
|
||||
void loadAllMeds();
|
||||
},
|
||||
[deleteMedImage, loadAllMeds]
|
||||
);
|
||||
|
||||
// Calculate total tablets
|
||||
const totalTablets = useMemo(() => {
|
||||
if (form.packageType === "bottle") {
|
||||
@@ -168,6 +249,8 @@ export function MedicationsPage() {
|
||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||
return packCount * blistersPerPack * pillsPerBlister;
|
||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||
const decrementValueLabel = t("editStock.decreaseValue");
|
||||
const incrementValueLabel = t("editStock.increaseValue");
|
||||
|
||||
const dateConsistencyError = useMemo(() => {
|
||||
const medicationStartDate = form.medicationStartDate;
|
||||
@@ -196,6 +279,8 @@ export function MedicationsPage() {
|
||||
|
||||
// Open mobile edit modal
|
||||
function openEditModal() {
|
||||
if (showEditModalRef.current) return;
|
||||
showEditModalRef.current = true;
|
||||
setShowEditModal(true);
|
||||
window.history.pushState({ modal: "edit" }, "");
|
||||
}
|
||||
@@ -446,7 +531,19 @@ export function MedicationsPage() {
|
||||
|
||||
// Upload image if pending (for new medications)
|
||||
if (!editingId && pendingImage && saved.id) {
|
||||
await uploadMedImage(saved.id, pendingImage);
|
||||
const uploaded = await tryUploadMedImage(saved.id, pendingImage);
|
||||
if (!uploaded) {
|
||||
// Keep user in edit mode so upload error stays visible and retry is immediate.
|
||||
setEditingId(saved.id);
|
||||
setFormSaved(true);
|
||||
setOriginalForm(form);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
loadMeds();
|
||||
void loadAllMeds();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
}
|
||||
@@ -463,6 +560,20 @@ export function MedicationsPage() {
|
||||
|
||||
// Reset form after successful save
|
||||
if (!editingId) {
|
||||
const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768;
|
||||
if (shouldCloseMobileModal) {
|
||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||
closeConfirmedRef.current = true;
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
setViewMode("grid");
|
||||
resetForm();
|
||||
window.history.back();
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
setViewMode("grid");
|
||||
} else {
|
||||
@@ -480,6 +591,8 @@ export function MedicationsPage() {
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||
|
||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||
if (showObsoleteConfirm) {
|
||||
setShowObsoleteConfirm(false);
|
||||
@@ -497,6 +610,11 @@ export function MedicationsPage() {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (currentEditMedId) {
|
||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
clearEditMedIdParam();
|
||||
}
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
@@ -515,6 +633,10 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (currentEditMedId) {
|
||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
}
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
@@ -562,38 +684,21 @@ export function MedicationsPage() {
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showEditModal, closeEditModal]);
|
||||
|
||||
// Handle edit button click - open modal on mobile, switch to form on desktop
|
||||
const normalizeMedicationForEdit = useCallback(
|
||||
(med: Medication): Medication => {
|
||||
if (med.packageType !== "blister") return med;
|
||||
|
||||
const pillsPerPack = Math.max(1, med.blistersPerPack * med.pillsPerBlister);
|
||||
const fallbackStock = Math.max(0, getMedTotal(med));
|
||||
const currentStock = Math.max(0, Math.round(coverageByMed[med.name]?.medsLeft ?? fallbackStock));
|
||||
const nextPackCount = Math.floor(currentStock / pillsPerPack);
|
||||
const nextLooseTablets = currentStock % pillsPerPack;
|
||||
|
||||
if (nextPackCount === med.packCount && nextLooseTablets === med.looseTablets) {
|
||||
return med;
|
||||
}
|
||||
|
||||
return {
|
||||
...med,
|
||||
packCount: nextPackCount,
|
||||
looseTablets: nextLooseTablets,
|
||||
};
|
||||
},
|
||||
[coverageByMed]
|
||||
);
|
||||
function scrollToTopForDesktopEdit() {
|
||||
if (window.innerWidth <= 768) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditClick(med: Medication) {
|
||||
const normalizedMed = normalizeMedicationForEdit(med);
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
setShowUnsavedConfirm(true);
|
||||
@@ -602,17 +707,17 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
scrollToTopForDesktopEdit();
|
||||
}
|
||||
|
||||
function handleViewClick(med: Medication) {
|
||||
const normalizedMed = normalizeMedicationForEdit(med);
|
||||
if (formChanged) {
|
||||
pendingActionRef.current = () => {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
};
|
||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||
@@ -622,7 +727,7 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(true);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizedMed, openEditModal);
|
||||
startEdit(med, openEditModal);
|
||||
setViewMode("form");
|
||||
}
|
||||
|
||||
@@ -685,19 +790,26 @@ export function MedicationsPage() {
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
startEdit(normalizeMedicationForEdit(medicationToEdit), openEditModal);
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
setViewMode("form");
|
||||
setPendingEditTransition(false);
|
||||
window.dispatchEvent(new Event("medassist:edit-transition-ready"));
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("editMedId");
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}, [allMeds, normalizeMedicationForEdit, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
|
||||
const selectedMedication = useMemo(() => {
|
||||
if (!editingId) return null;
|
||||
return allMeds.find((med) => med.id === editingId) ?? null;
|
||||
}, [allMeds, editingId]);
|
||||
|
||||
// While navigating from detail modal to edit, render nothing until form is populated
|
||||
if (pendingEditTransition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`med-grid-wrapper${viewMode === "form" ? " desktop-edit-open" : ""}`}>
|
||||
{/* ── Grid View: always visible medication cards ── */}
|
||||
@@ -820,8 +932,10 @@ export function MedicationsPage() {
|
||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
||||
{"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 && (
|
||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||
{" "}
|
||||
<Bell size={12} aria-hidden="true" />
|
||||
@@ -958,15 +1072,6 @@ export function MedicationsPage() {
|
||||
>
|
||||
{t("form.sections.stock")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "prescription"}
|
||||
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("prescription")}
|
||||
>
|
||||
{t("form.sections.prescription")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -976,6 +1081,15 @@ export function MedicationsPage() {
|
||||
>
|
||||
{t("form.sections.schedule")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === "prescription"}
|
||||
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||
onClick={() => setActiveTab("prescription")}
|
||||
>
|
||||
{t("form.sections.prescription")}
|
||||
</button>
|
||||
</div>
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
||||
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||
@@ -1023,7 +1137,9 @@ export function MedicationsPage() {
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleValueChange("packageType", e.target.value as import("../types").PackageType)
|
||||
}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
@@ -1085,7 +1201,7 @@ export function MedicationsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="danger icon-only tooltip-trigger"
|
||||
onClick={() => deleteMedImage(editingId)}
|
||||
onClick={() => handleDeleteMedImage(editingId)}
|
||||
aria-label={t("form.removeImage")}
|
||||
data-tooltip={t("form.removeImage")}
|
||||
>
|
||||
@@ -1098,7 +1214,11 @@ export function MedicationsPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void tryUploadMedImage(editingId, file);
|
||||
}}
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
);
|
||||
@@ -1126,18 +1246,11 @@ export function MedicationsPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPendingImage(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
onChange={handlePendingMedicationImageSelection}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* end general tab */}
|
||||
@@ -1149,32 +1262,32 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -1186,22 +1299,22 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(e) => handleValueChange("totalPills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
@@ -1292,32 +1405,32 @@ export function MedicationsPage() {
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => handleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => handleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => handleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
onChange={(nextValue) => handleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
@@ -1354,22 +1467,24 @@ export function MedicationsPage() {
|
||||
<div className="blister-inputs">
|
||||
<label>
|
||||
{t("form.blisters.usage")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blisters.everyDays")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
|
||||
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -1478,8 +1593,9 @@ export function MedicationsPage() {
|
||||
onRemoveIntake={removeIntake}
|
||||
onHandleValueChange={handleValueChange}
|
||||
meds={allMeds}
|
||||
onUploadMedImage={uploadMedImage}
|
||||
onDeleteMedImage={deleteMedImage}
|
||||
onUploadMedImage={handleUploadMedImage}
|
||||
onDeleteMedImage={handleDeleteMedImage}
|
||||
imageUploadError={imageUploadError}
|
||||
onClose={() => {
|
||||
closeEditModal();
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
@@ -65,6 +67,7 @@ export function SchedulePage() {
|
||||
pastDays,
|
||||
futureDays,
|
||||
takenDoses,
|
||||
isDoseTakenAutomatically,
|
||||
dismissedDoses,
|
||||
markDoseTaken,
|
||||
undoDoseTaken,
|
||||
@@ -204,13 +207,15 @@ export function SchedulePage() {
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}{" "}
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
@@ -230,6 +235,14 @@ export function SchedulePage() {
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
@@ -365,13 +378,15 @@ export function SchedulePage() {
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div
|
||||
@@ -395,6 +410,11 @@ export function SchedulePage() {
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
@@ -89,7 +90,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
checked={settings.emailStockReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -100,9 +101,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
||||
}
|
||||
checked={settings.shoutrrrStockReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -116,7 +115,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
checked={settings.emailIntakeReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -127,9 +126,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
||||
}
|
||||
checked={settings.shoutrrrIntakeReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -143,9 +140,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
|
||||
}
|
||||
checked={settings.emailPrescriptionReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -156,11 +151,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled
|
||||
? settings.shoutrrrPrescriptionReminders
|
||||
: false
|
||||
}
|
||||
checked={settings.shoutrrrPrescriptionReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Coverage } from "../types";
|
||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||
return userId ? `user_${userId}_${key}` : key;
|
||||
}
|
||||
|
||||
export function getBlisterStock(
|
||||
totalPills: number,
|
||||
pillsPerBlister: number,
|
||||
looseTablets: number,
|
||||
_originalTotal: number
|
||||
) {
|
||||
return splitCurrentBlisterStock(totalPills, pillsPerBlister, looseTablets);
|
||||
}
|
||||
|
||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||
}
|
||||
|
||||
export function formatOpenBlisterAndLoose(
|
||||
openBlisterPills: number,
|
||||
loosePills: number,
|
||||
pillsPerBlister: number,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (openBlisterPills > 0 && loosePills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")} + ${loosePills} ${t("modal.loosePills")}`;
|
||||
}
|
||||
if (openBlisterPills > 0) {
|
||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||
}
|
||||
if (loosePills > 0) {
|
||||
return `${loosePills} ${t("modal.loosePills")}`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function getMedTotal(med: {
|
||||
packCount: number;
|
||||
blistersPerPack: number;
|
||||
pillsPerBlister: number;
|
||||
looseTablets: number;
|
||||
stockAdjustment?: number | null;
|
||||
packageType?: string;
|
||||
}): number {
|
||||
return getMedTotalFromTypes(med);
|
||||
}
|
||||
|
||||
export function getReminderStatusData(
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
_allLowCoverage: Coverage[],
|
||||
allCoverage: Coverage[],
|
||||
lastAutoEmailSent: string | null,
|
||||
_lastNotificationType: string | null,
|
||||
_lastNotificationChannel: string | null,
|
||||
lastReminderMedName: string | null,
|
||||
lastReminderTakenBy: string | null,
|
||||
lastStockReminderSent: string | null,
|
||||
_lastStockReminderChannel: string | null,
|
||||
lastStockReminderMedNames: string | null,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
locale: string
|
||||
): {
|
||||
status: { text: string; className: string };
|
||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||
lastStockSent: { date: string; medNames: string | null } | null;
|
||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.daysLeft === null) continue;
|
||||
|
||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||
const isLow = c.daysLeft < lowStockDays;
|
||||
if (!isCritical && !isLow) continue;
|
||||
|
||||
const existing = lowStockMap.get(c.name);
|
||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||
}
|
||||
}
|
||||
|
||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||
|
||||
let status: { text: string; className: string };
|
||||
if (criticalCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||
className: "danger",
|
||||
};
|
||||
} else if (lowCount > 0) {
|
||||
status = {
|
||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||
className: "warning",
|
||||
};
|
||||
} else {
|
||||
status = {
|
||||
text: t("dashboard.reminders.allOk"),
|
||||
className: "success",
|
||||
};
|
||||
}
|
||||
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
const sentDate = new Date(lastStockReminderSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastStockSent = {
|
||||
date: formattedDate,
|
||||
medNames: lastStockReminderMedNames,
|
||||
};
|
||||
}
|
||||
|
||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
const sentDate = new Date(lastAutoEmailSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastIntakeSent = {
|
||||
date: formattedDate,
|
||||
medName: lastReminderMedName,
|
||||
takenBy: lastReminderTakenBy,
|
||||
};
|
||||
}
|
||||
|
||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||
}
|
||||
+84
-22
@@ -108,6 +108,22 @@ body.modal-open {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.route-transition-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg-primary);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease-out;
|
||||
z-index: 1500;
|
||||
}
|
||||
|
||||
.route-transition-mask.active {
|
||||
transition: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
||||
border: 1px solid var(--border-primary);
|
||||
@@ -1127,11 +1143,15 @@ body.modal-open {
|
||||
}
|
||||
.blister-row .blister-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.05fr) minmax(10.75rem, 1fr) minmax(7.25rem, 0.8fr);
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.blister-row .blister-inputs > label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blister-row .blister-inputs label.taken-by-field {
|
||||
grid-column: span 2;
|
||||
}
|
||||
@@ -1154,6 +1174,17 @@ body.modal-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop edit sidebar can be narrow; avoid clipping right-side controls. */
|
||||
@media (min-width: 769px) {
|
||||
.edit-sidebar .blister-row .blister-inputs {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.edit-sidebar .blister-row .blister-inputs label.taken-by-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.gap {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
@@ -2212,6 +2243,9 @@ button.has-validation-error {
|
||||
.time-main .med-name span.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-main .med-name .med-name-stack.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.time-main .med-name span.clickable:hover .med-avatar {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
@@ -3373,7 +3407,7 @@ button.has-validation-error {
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 100;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3391,7 +3425,7 @@ button.has-validation-error {
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 101;
|
||||
z-index: 1101;
|
||||
}
|
||||
|
||||
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
||||
@@ -4323,15 +4357,16 @@ button.has-validation-error {
|
||||
/* Modal base styles moved to styles/modals-base.css */
|
||||
|
||||
/* Medication Detail Modal */
|
||||
.med-detail-modal {
|
||||
.modal-content.med-detail-modal {
|
||||
padding: 0;
|
||||
width: min(100vw - 1rem, 520px);
|
||||
width: min(100vw - 1rem, 711px);
|
||||
max-width: 711px;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-primary);
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.med-detail-modal .med-detail-body {
|
||||
@@ -4378,6 +4413,7 @@ button.has-validation-error {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.taken-by-badge {
|
||||
@@ -4530,7 +4566,7 @@ button.has-validation-error {
|
||||
}
|
||||
|
||||
.med-detail-body {
|
||||
padding: 1.5rem 2rem 0;
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
@@ -4604,9 +4640,6 @@ button.has-validation-error {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-value {
|
||||
}
|
||||
|
||||
.med-detail-item {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
@@ -4649,8 +4682,8 @@ button.has-validation-error {
|
||||
.med-schedule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
@@ -4664,10 +4697,30 @@ button.has-validation-error {
|
||||
|
||||
.med-schedule-freq {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.med-schedule-time {
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.med-schedule-person {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.med-schedule-bell {
|
||||
color: var(--warning);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .med-schedule-bell {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.med-detail-footer {
|
||||
@@ -4680,16 +4733,16 @@ button.has-validation-error {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0 0 12px 12px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
margin: 0 -2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile devices can report wide CSS viewports (e.g., 768px in device emulation).
|
||||
Use input modality instead of width-only breakpoints so the modal still fills the handset viewport. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
@media (hover: none) and (pointer: coarse) and (max-width: 500px) {
|
||||
.med-detail-overlay {
|
||||
padding: 0.4rem;
|
||||
align-items: stretch;
|
||||
@@ -4912,7 +4965,7 @@ button.has-validation-error {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 0 -1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.med-detail-footer > button {
|
||||
@@ -4938,7 +4991,7 @@ button.has-validation-error {
|
||||
}
|
||||
|
||||
/* Hard mobile override for MedDetailModal: remove side frame and use full handset viewport. */
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 500px) {
|
||||
.modal-overlay.med-detail-overlay {
|
||||
padding: 0 !important;
|
||||
align-items: stretch;
|
||||
@@ -4969,15 +5022,15 @@ button.has-validation-error {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reminder icon indicator */
|
||||
.reminder-icon.info-tooltip,
|
||||
.notes-icon.info-tooltip {
|
||||
.notes-icon.info-tooltip,
|
||||
.prescription-icon.info-tooltip {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 !important;
|
||||
@@ -4992,6 +5045,10 @@ button.has-validation-error {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.prescription-icon.info-tooltip {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.reminder-icon.info-tooltip,
|
||||
.blister-reminder-icon {
|
||||
color: var(--warning);
|
||||
@@ -5006,8 +5063,13 @@ button.has-validation-error {
|
||||
color: #1d4ed8; /* darker blue — strong contrast on light backgrounds */
|
||||
}
|
||||
|
||||
[data-theme="light"] .prescription-icon.info-tooltip {
|
||||
color: #047857; /* dark emerald — strong contrast on light backgrounds */
|
||||
}
|
||||
|
||||
.reminder-icon.info-tooltip:hover,
|
||||
.notes-icon.info-tooltip:hover {
|
||||
.notes-icon.info-tooltip:hover,
|
||||
.prescription-icon.info-tooltip:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -446,7 +446,7 @@
|
||||
}
|
||||
|
||||
.refill-number-stepper input {
|
||||
order: initial;
|
||||
order: 0;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
@@ -460,21 +460,29 @@
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.decrement {
|
||||
order: initial;
|
||||
order: -1;
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.increment {
|
||||
order: initial;
|
||||
order: 1;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: none;
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
@@ -488,12 +496,12 @@
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
background: color-mix(in srgb, #dc2626 18%, white);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
background: color-mix(in srgb, #0f766e 18%, white);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
@@ -504,6 +512,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Form stepper keeps symmetric - value + layout in all contexts (desktop/mobile). */
|
||||
.form-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.form-number-stepper input {
|
||||
order: 0;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn {
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
border-left: none;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.decrement {
|
||||
order: -1;
|
||||
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.increment {
|
||||
order: 1;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.form-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
/* Highlight both controls when the center value field is focused (keyboard/click). */
|
||||
.form-number-stepper:has(input:focus) .stepper-btn.decrement:not(:disabled),
|
||||
.form-number-stepper:has(input:focus-visible) .stepper-btn.decrement:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.form-number-stepper:has(input:focus) .stepper-btn.increment:not(:disabled),
|
||||
.form-number-stepper:has(input:focus-visible) .stepper-btn.increment:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
/* Dense schedule grids need a compact variant so the middle value stays visible. */
|
||||
.blister-inputs .form-number-stepper,
|
||||
.mobile-edit-form .blister-row .form-number-stepper {
|
||||
grid-template-columns: 2.35rem minmax(2rem, 1fr) 2.35rem;
|
||||
}
|
||||
|
||||
.blister-inputs .form-number-stepper input,
|
||||
.mobile-edit-form .blister-row .form-number-stepper input {
|
||||
min-height: 2.35rem;
|
||||
padding: 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
.blister-inputs .form-number-stepper .stepper-btn,
|
||||
.mobile-edit-form .blister-row .form-number-stepper .stepper-btn {
|
||||
min-width: 2.35rem;
|
||||
min-height: 2.35rem;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.form-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.form-number-stepper input {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .form-number-stepper .stepper-btn.decrement {
|
||||
background: color-mix(in srgb, #dc2626 18%, white);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .form-number-stepper .stepper-btn.increment {
|
||||
background: color-mix(in srgb, #0f766e 18%, white);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.form-number-stepper {
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-stock-summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 450px);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
max-height: min(85dvh, 85vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -190,17 +190,27 @@
|
||||
}
|
||||
|
||||
/* Mobile Edit Modal */
|
||||
.mobile-edit-overlay {
|
||||
align-items: flex-start;
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
max-height: none;
|
||||
height: min(96dvh, 96vh);
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -208,12 +218,91 @@
|
||||
.edit-modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-edit-form.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.mobile-edit-form .modal-footer {
|
||||
border-top: none;
|
||||
padding: 0.45rem 0.15rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
||||
gap: 0.6rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mobile-edit-form .readonly-fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-inline-size: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mobile-edit-form .readonly-fieldset.swiping-horizontal {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.mobile-edit-form .mobile-tab-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-edit-form .mobile-tab-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.mobile-edit-form .mobile-tab-track:not(.is-swiping) {
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.mobile-edit-form .mobile-tab-track > .form-tab-panel {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-right: 0.1rem;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.mobile-edit-form .form-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.mobile-edit-form .form-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-edit-form .form-tab {
|
||||
flex: 0 0 auto;
|
||||
min-width: max-content;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.mobile-edit-form .form-tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-edit-form.form-grid > label {
|
||||
|
||||
@@ -284,24 +284,6 @@ describe("App", () => {
|
||||
expect(screen.getByText("lightbox-open-med-image.png")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles Escape key with modal priority", () => {
|
||||
appContextMock.scheduleLightboxImage = "med-image.png";
|
||||
appContextMock.showImageLightbox = true;
|
||||
appContextMock.showShareDialog = true;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
|
||||
expect(appContextMock.closeScheduleLightbox).toHaveBeenCalled();
|
||||
expect(appContextMock.closeImageLightbox).not.toHaveBeenCalled();
|
||||
expect(appContextMock.closeShareDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles popstate by closing selected medication", () => {
|
||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
||||
|
||||
@@ -344,20 +326,6 @@ describe("App", () => {
|
||||
expect(window.history.pushState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape key closes about modal via history back", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "open-about" }));
|
||||
expect(screen.getByText("about-modal-open")).toBeInTheDocument();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(window.history.back).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles popstate by resetting share dialog state", () => {
|
||||
appContextMock.showShareDialog = true;
|
||||
|
||||
@@ -381,47 +349,6 @@ describe("App", () => {
|
||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Escape closes refill modal when it is topmost", () => {
|
||||
appContextMock.showRefillModal = true;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(appContextMock.closeRefillModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape closes edit stock modal when it is topmost", () => {
|
||||
appContextMock.showEditStockModal = true;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(appContextMock.closeEditStockModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Escape closes user filter and medication detail in lower priority", () => {
|
||||
appContextMock.selectedUser = "Max";
|
||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(appContextMock.closeUserFilter).toHaveBeenCalled();
|
||||
expect(appContextMock.closeMedDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("popstate closes image lightbox before other modals", () => {
|
||||
appContextMock.showImageLightbox = true;
|
||||
appContextMock.scheduleLightboxImage = "img.png";
|
||||
@@ -450,17 +377,4 @@ describe("App", () => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
expect(appContextMock.setScheduleLightboxImage).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("Escape closes medication detail when no higher-priority modal is open", () => {
|
||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect(appContextMock.closeMedDetail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -370,10 +370,13 @@ describe("AppHeader", () => {
|
||||
fireEvent.click(userMenuBtn);
|
||||
fireEvent.click(screen.getByText(/auth\.signOut/i));
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/auth/logout",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("AuthProvider", () => {
|
||||
renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("AuthProvider", () => {
|
||||
|
||||
// Wait for the initial fetch to complete
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
|
||||
});
|
||||
|
||||
// Wait a bit more to ensure no additional calls happen
|
||||
@@ -94,18 +94,21 @@ describe("AuthProvider", () => {
|
||||
const response = await result.current.authFetch("/api/medications", { method: "GET" });
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(fetch).toHaveBeenNthCalledWith(2, "/api/medications", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(3, "/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(4, "/api/medications", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/medications",
|
||||
expect.objectContaining({ method: "GET", credentials: "include" })
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/api/auth/refresh",
|
||||
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"/api/medications",
|
||||
expect.objectContaining({ method: "GET", credentials: "include" })
|
||||
);
|
||||
});
|
||||
|
||||
it("authFetch logs user out when refresh fails", async () => {
|
||||
@@ -556,7 +559,7 @@ describe("UserProfile", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
||||
const cancelBtn = screen.getByText(/common\.close/i);
|
||||
fireEvent.click(cancelBtn);
|
||||
});
|
||||
|
||||
@@ -893,7 +896,7 @@ describe("AuthProvider methods", () => {
|
||||
});
|
||||
|
||||
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
||||
await expect(result.current.uploadAvatar(file)).rejects.toThrow("Upload failed");
|
||||
await expect(result.current.uploadAvatar(file)).rejects.toThrow("UNKNOWN");
|
||||
});
|
||||
|
||||
it("deleteAvatar succeeds and refreshes user", async () => {
|
||||
|
||||
@@ -70,12 +70,12 @@ describe("ExportModal", () => {
|
||||
|
||||
it("renders cancel button", () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/common\.close/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when cancel button is clicked", () => {
|
||||
render(<ExportModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
|
||||
fireEvent.click(screen.getByText(/common\.close/i));
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -48,9 +48,10 @@ describe("Lightbox", () => {
|
||||
|
||||
it("calls onClose when Escape key is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
const { container } = render(<Lightbox {...defaultProps} onClose={onClose} />);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
const overlay = container.querySelector(".lightbox-overlay");
|
||||
fireEvent.keyDown(overlay!, { key: "Escape" });
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -216,6 +216,32 @@ describe("MedDetailModal", () => {
|
||||
const body = document.querySelector(".med-detail-body");
|
||||
expect(body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows configured pack count in package details, independent from current stock", () => {
|
||||
const medWithConfiguredPacks: Medication = {
|
||||
...mockMedication,
|
||||
packCount: 11,
|
||||
blistersPerPack: 5,
|
||||
pillsPerBlister: 5,
|
||||
};
|
||||
|
||||
const lowCurrentStockCoverage: Coverage = {
|
||||
...mockCoverage,
|
||||
medsLeft: 47,
|
||||
};
|
||||
|
||||
render(
|
||||
<MedDetailModal
|
||||
{...defaultProps}
|
||||
selectedMed={medWithConfiguredPacks}
|
||||
coverage={{ all: [lowCurrentStockCoverage] }}
|
||||
/>
|
||||
);
|
||||
|
||||
const packsLabel = screen.getByText(/modal\.packs/i);
|
||||
const packsValue = packsLabel.closest(".med-detail-item")?.querySelector(".med-detail-value");
|
||||
expect(packsValue?.textContent).toBe("11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MedDetailModal without coverage", () => {
|
||||
@@ -744,7 +770,7 @@ describe("MedDetailModal stock overflow warning", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not show overflow warning icon with live stock denominator", () => {
|
||||
it("shows overflow warning icon when stock exceeds blister package capacity", () => {
|
||||
const overflowCoverage: Coverage = {
|
||||
name: "Test Med",
|
||||
medsLeft: 49,
|
||||
@@ -756,9 +782,9 @@ describe("MedDetailModal stock overflow warning", () => {
|
||||
|
||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
|
||||
|
||||
// Live denominator uses current stock, so overflow warning is not shown in detail row.
|
||||
// For blister meds, denominator is package capacity (not current stock), so overflow is shown.
|
||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show warning icon when stock is within package capacity", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ReportModal", () => {
|
||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||
|
||||
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.cancel/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ describe("ShareDialog", () => {
|
||||
|
||||
it("calls onClose when close button is clicked", () => {
|
||||
render(<ShareDialog {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
|
||||
fireEvent.click(closeButtons[closeButtons.length - 1]);
|
||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useMedications } from "../../hooks/useMedications";
|
||||
import type { Medication } from "../../types";
|
||||
|
||||
describe("useMedications", () => {
|
||||
beforeEach(() => {
|
||||
@@ -169,9 +170,11 @@ describe("useMedications", () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
const file = new File(["test"], "test.jpg", { type: "image/jpeg" });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
});
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.uploadMedImage(1, file);
|
||||
})
|
||||
).rejects.toThrow("Upload failed");
|
||||
|
||||
expect(result.current.uploadingImage).toBe(false);
|
||||
});
|
||||
@@ -193,7 +196,7 @@ describe("useMedications", () => {
|
||||
it("allows setting meds directly", () => {
|
||||
const { result } = renderHook(() => useMedications());
|
||||
|
||||
const newMeds = [{ id: 1, name: "NewMed" }] as any;
|
||||
const newMeds: Array<Pick<Medication, "id" | "name">> = [{ id: 1, name: "NewMed" }];
|
||||
|
||||
act(() => {
|
||||
result.current.setMeds(newMeds);
|
||||
|
||||
@@ -245,8 +245,8 @@ describe("useSettings", () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
// emailEnabled should be false in the saved state
|
||||
expect(result.current.settings.emailEnabled).toBe(false);
|
||||
// Local state preserves user choice; backend receives effective value via payload
|
||||
expect(result.current.settings.emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-disables shoutrrr when URL is empty", async () => {
|
||||
@@ -274,7 +274,8 @@ describe("useSettings", () => {
|
||||
await result.current.saveSettings(mockEvent);
|
||||
});
|
||||
|
||||
expect(result.current.settings.shoutrrrEnabled).toBe(false);
|
||||
// Local state preserves user choice; backend receives effective value via payload
|
||||
expect(result.current.settings.shoutrrrEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes reminder status on interval", async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("useShare", () => {
|
||||
mockAlert = vi.fn();
|
||||
global.alert = mockAlert;
|
||||
|
||||
mockClipboard = { writeText: vi.fn() };
|
||||
mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: mockClipboard,
|
||||
writable: true,
|
||||
@@ -237,7 +237,7 @@ describe("useShare", () => {
|
||||
result.current.setShareLink("http://localhost:5173/share/test-token");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
result.current.copyShareLink();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DashboardPage } from "../../pages/DashboardPage";
|
||||
import {
|
||||
DashboardPage,
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
getBlisterStock,
|
||||
getMedTotal,
|
||||
getReminderStatusData,
|
||||
userStorageKey,
|
||||
} from "../../pages/DashboardPage";
|
||||
} from "../../pages/dashboard-helpers";
|
||||
|
||||
// Mock data for tests with medications
|
||||
const mockMeds = [
|
||||
@@ -181,6 +181,7 @@ const createMockAppContext = (overrides = {}) => ({
|
||||
missedPastDoseIds: [],
|
||||
getDayStockStatus: vi.fn(() => "success"),
|
||||
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
|
||||
isDoseTakenAutomatically: vi.fn(() => false),
|
||||
showClearMissedConfirm: false,
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearingMissed: false,
|
||||
@@ -198,7 +199,7 @@ describe("DashboardPage helper functions", () => {
|
||||
});
|
||||
|
||||
it("calculates blister stock breakdown", () => {
|
||||
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 7 });
|
||||
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 0 });
|
||||
});
|
||||
|
||||
it("formats blister and open blister labels", () => {
|
||||
@@ -206,7 +207,7 @@ describe("DashboardPage helper functions", () => {
|
||||
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
|
||||
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
|
||||
expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-");
|
||||
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills");
|
||||
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills + 4 modal.loosePills");
|
||||
});
|
||||
|
||||
it("computes total pills for blister and bottle types", () => {
|
||||
|
||||
@@ -124,6 +124,7 @@ const fetchMock = vi.fn();
|
||||
vi.mock("../../hooks", () => ({
|
||||
useMedicationForm: () => mockFormHookValue,
|
||||
useUnsavedChangesWarning: () => ({}),
|
||||
useModalHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../context", () => ({
|
||||
|
||||
@@ -111,6 +111,7 @@ const createMockContext = (overrides = {}) => ({
|
||||
manuallyExpandedDays: new Set(),
|
||||
toggleDayCollapse: vi.fn(),
|
||||
openUserFilter: vi.fn(),
|
||||
isDoseTakenAutomatically: vi.fn(() => false),
|
||||
missedPastDoseIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -201,7 +201,7 @@ describe("getBlisterStock", () => {
|
||||
|
||||
const result = getBlisterStock(med);
|
||||
expect(result.fullBlisters).toBe(2); // 25 / 10 = 2
|
||||
expect(result.openBlisterPills).toBe(5); // 25 % 10 = 5
|
||||
expect(result.openBlisterPills).toBe(0); // 20 % 10 = 0 after preserving loose tablets
|
||||
expect(result.loosePills).toBe(5);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export type PlannerRow = {
|
||||
medicationId: number;
|
||||
medicationName: string;
|
||||
totalPills: number;
|
||||
currentPills?: number;
|
||||
plannerUsage: number;
|
||||
blisterSize: number;
|
||||
blistersNeeded: number;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function createCorrelationId(prefix: string = "fe"): string {
|
||||
const randomPart = Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}-${Date.now().toString(36)}-${randomPart}`;
|
||||
}
|
||||
|
||||
export function withCorrelation(
|
||||
init?: RequestInit,
|
||||
prefix: string = "fe"
|
||||
): { correlationId: string; init: RequestInit } {
|
||||
const correlationId = createCorrelationId(prefix);
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
headers.set("x-correlation-id", correlationId);
|
||||
return {
|
||||
correlationId,
|
||||
init: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { BlisterStock, Medication } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { splitCurrentBlisterStock } from "./stock";
|
||||
|
||||
/**
|
||||
* Map timezone to region code (ISO 3166-1 alpha-2).
|
||||
@@ -302,12 +304,7 @@ export function getExpiryClass(expiryDate: string | null | undefined, thresholdD
|
||||
* Calculate blister stock breakdown for a medication
|
||||
*/
|
||||
export function getBlisterStock(med: Medication): BlisterStock {
|
||||
const total =
|
||||
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const bSize = med.pillsPerBlister;
|
||||
const fullBlisters = Math.floor(total / bSize);
|
||||
const openBlisterPills = total % bSize;
|
||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
||||
return splitCurrentBlisterStock(getMedTotal(med), med.pillsPerBlister, med.looseTablets);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Error codes returned by the backend image upload endpoints. */
|
||||
const IMAGE_ERROR_CODE_MAP: Record<string, string> = {
|
||||
IMAGE_TOO_LARGE: "form.imageUploadErrors.tooLarge",
|
||||
INVALID_TYPE: "form.imageUploadErrors.invalidType",
|
||||
INVALID_IMAGE: "form.imageUploadErrors.invalidImage",
|
||||
NO_FILE: "form.imageUploadErrors.noFile",
|
||||
NETWORK_ERROR: "common.networkError",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a backend image-upload error code to a translated user-facing message.
|
||||
* Falls back to a generic error when the code is unknown.
|
||||
*/
|
||||
export function resolveImageUploadError(code: string, t: TFunction): string {
|
||||
const normalized = normalizeErrorCode(code);
|
||||
const key = IMAGE_ERROR_CODE_MAP[normalized];
|
||||
return key ? t(key) : t("form.imageUploadErrors.generic");
|
||||
}
|
||||
|
||||
/** Browser network errors are not error codes — normalise them. */
|
||||
function normalizeErrorCode(code: string): string {
|
||||
if (code === "Failed to fetch" || code.startsWith("NetworkError")) {
|
||||
return "NETWORK_ERROR";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -5,4 +5,5 @@
|
||||
export * from "./formatters";
|
||||
export * from "./ics";
|
||||
export * from "./schedule";
|
||||
export * from "./stock";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export type BlisterStockSplit = {
|
||||
fullBlisters: number;
|
||||
openBlisterPills: number;
|
||||
loosePills: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split current blister stock into sealed full blisters, open blister pills,
|
||||
* and loose pills using the configured loose-tablets baseline.
|
||||
*/
|
||||
export function splitCurrentBlisterStock(
|
||||
currentPills: number,
|
||||
pillsPerBlister: number,
|
||||
configuredLooseTablets: number
|
||||
): BlisterStockSplit {
|
||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: Math.max(0, currentPills) };
|
||||
}
|
||||
|
||||
const safeCurrent = Math.max(0, currentPills);
|
||||
const loosePills = Math.min(safeCurrent, Math.max(0, configuredLooseTablets));
|
||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
||||
|
||||
return {
|
||||
fullBlisters: Math.floor(sealedPills / pillsPerBlister),
|
||||
openBlisterPills: sealedPills % pillsPerBlister,
|
||||
loosePills,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper when medication object already contains stock fields.
|
||||
*/
|
||||
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
||||
const total =
|
||||
(med.packageType === "bottle"
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0)) ?? 0;
|
||||
|
||||
return splitCurrentBlisterStock(total, med.pillsPerBlister, med.looseTablets);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user