Compare commits
61 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 | |||
| 8f57aa8bc9 | |||
| f42ed87d94 | |||
| 8de54b9065 | |||
| b489e1e117 | |||
| 8c97abd3c9 | |||
| 2eec415af6 | |||
| 243a46f960 | |||
| 052751b2ba | |||
| 89d565bc9d | |||
| 08a18fc14a | |||
| e41efdf98b | |||
| cefac8cc4e | |||
| 779870960c | |||
| 871e6066ec | |||
| ff100dfea5 | |||
| 47581ca7ad | |||
| 39e9ebbf28 | |||
| 41b20bb4e6 | |||
| f9c51956d5 | |||
| 543b42b540 | |||
| 36a2f7d537 |
@@ -7,6 +7,10 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to report a bug! Please fill out the sections below.
|
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
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
@@ -57,6 +61,18 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
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
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -89,6 +89,29 @@ PR #141: "fix: planner checkbox layout on single line"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## PR Metadata (MANDATORY)
|
||||||
|
|
||||||
|
Every Pull Request MUST have the following sidebar fields populated at creation time:
|
||||||
|
|
||||||
|
| Field | Value | How |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
|
||||||
|
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
|
||||||
|
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
|
||||||
|
|
||||||
|
**Label mapping for PRs:**
|
||||||
|
| Branch prefix / commit type | Label |
|
||||||
|
|---|---|
|
||||||
|
| `feat/` | `enhancement` |
|
||||||
|
| `fix/` | `bug` |
|
||||||
|
| `docs/` | `documentation` |
|
||||||
|
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
|
||||||
|
| `chore/release-*` | No label needed (release PRs are automated) |
|
||||||
|
|
||||||
|
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Task 1: Branch, PR, and Merge Workflow
|
## Task 1: Branch, PR, and Merge Workflow
|
||||||
|
|
||||||
When code changes (features or bug fixes) are complete:
|
When code changes (features or bug fixes) are complete:
|
||||||
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
|
|||||||
```bash
|
```bash
|
||||||
git push -u origin feat/short-description
|
git push -u origin feat/short-description
|
||||||
```
|
```
|
||||||
2. Create a Pull Request via GitHub CLI, linking the related issue:
|
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||||
```bash
|
```bash
|
||||||
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
|
gh pr create \
|
||||||
|
--title "fix: short description" \
|
||||||
|
--body "Closes #<ISSUE_NUMBER>
|
||||||
|
|
||||||
Description of changes"
|
Description of changes" \
|
||||||
|
--assignee DanielVolz \
|
||||||
|
--label bug \
|
||||||
|
--project "@DanielVolz's MedAssist-ng project"
|
||||||
```
|
```
|
||||||
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
|
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||||
|
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||||
|
- The `--project` flag links the PR to the Project board.
|
||||||
3. **Present the PR URL to the user and wait for confirmation.**
|
3. **Present the PR URL to the user and wait for confirmation.**
|
||||||
|
|
||||||
### Step 4: Wait for CI and Merge
|
### Step 4: Wait for CI and Merge
|
||||||
@@ -462,7 +492,7 @@ Code complete & validated by testing-manager
|
|||||||
↓
|
↓
|
||||||
1. Ensure a GitHub issue exists (create if not)
|
1. Ensure a GitHub issue exists (create if not)
|
||||||
2. Create feature branch (fix/... or feat/...)
|
2. Create feature branch (fix/... or feat/...)
|
||||||
3. Commit, push, create PR (with "Closes #N" in body)
|
3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
|
||||||
4. Wait for CI (all required checks)
|
4. Wait for CI (all required checks)
|
||||||
5. Merge PR to main (squash + delete branch)
|
5. Merge PR to main (squash + delete branch)
|
||||||
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
|||||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
||||||
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
||||||
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
||||||
|
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
||||||
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
||||||
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
||||||
|
|
||||||
@@ -67,8 +68,8 @@ cd frontend && npm run build
|
|||||||
```bash
|
```bash
|
||||||
cd frontend && npm run test:e2e
|
cd frontend && npm run test:e2e
|
||||||
cd frontend && npm run test:e2e -- --project=chromium
|
cd frontend && npm run test:e2e -- --project=chromium
|
||||||
cd frontend && npm run test:e2e:ui
|
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||||
cd frontend && npm run test:e2e:headed
|
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backend Test Patterns
|
## Backend Test Patterns
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Use this orientation for quick navigation before applying the rules below.
|
|||||||
- Testing work belongs to `@testing-manager`.
|
- Testing work belongs to `@testing-manager`.
|
||||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
- PR/release/CI orchestration belongs to `@release-manager`.
|
||||||
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
||||||
|
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
|
||||||
- Remove obsolete code when re-implementing — never leave dead code behind.
|
- Remove obsolete code when re-implementing — never leave dead code behind.
|
||||||
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Avoid custom inline modal/button patterns that diverge from project design.
|
||||||
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
|
- 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
|
## Decision Heuristics
|
||||||
|
|
||||||
1. If an equivalent component exists, reuse it.
|
1. If an equivalent component exists, reuse it.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||||
steps:
|
steps:
|
||||||
- name: Move project item to Done
|
- name: Move project item to Done
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ Thumbs.db
|
|||||||
.turbo/
|
.turbo/
|
||||||
.roo/
|
.roo/
|
||||||
.roomodes
|
.roomodes
|
||||||
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku
|
||||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"vitest.root": "backend",
|
"vitest.root": "backend",
|
||||||
"vitest.enable": true,
|
"vitest.enable": true,
|
||||||
"vitest.commandLine": "npm test --"
|
"vitest.commandLine": "npm test --",
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"test": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+49
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "E2E stable",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E stable + merged video",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:with-video"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E all browsers",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:all"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E all browsers + merged video",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:all:with-video"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<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-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-771%2F771-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -123,6 +123,7 @@ Share your medication schedule with others via a public link.
|
|||||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
|
- Manual stock correction supports partial blisters and loose pills
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with pack or loose pill options
|
||||||
@@ -132,6 +133,7 @@ Share your medication schedule with others via a public link.
|
|||||||
### Flexible Schedules
|
### Flexible Schedules
|
||||||
- Daily, weekly, or custom intervals per medication
|
- Daily, weekly, or custom intervals per medication
|
||||||
- Independent schedules for each medication
|
- Independent schedules for each medication
|
||||||
|
- Optional timeline filters for dashboard and shared schedule views
|
||||||
|
|
||||||
### Stock Alerts & Reminders
|
### Stock Alerts & Reminders
|
||||||
- Notifications before stock runs out
|
- Notifications before stock runs out
|
||||||
@@ -143,6 +145,10 @@ Share your medication schedule with others via a public link.
|
|||||||
- Plan ahead for vacations, business trips, or hospital stays
|
- Plan ahead for vacations, business trips, or hospital stays
|
||||||
- Send demand reports via email or push notification
|
- Send demand reports via email or push notification
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
- Generate medication reports as PDF, Markdown, or plain text
|
||||||
|
- Include intake history, refill history, and prescription details
|
||||||
|
|
||||||
### Multi-Person Support
|
### Multi-Person Support
|
||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||||
|
|||||||
@@ -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,
|
"when": 1771164000000,
|
||||||
"tag": "0009_add_medication_start_date",
|
"tag": "0009_add_medication_start_date",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771694832866,
|
||||||
|
"tag": "0010_mean_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+588
-1422
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.11.1",
|
"version": "1.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,12 +32,13 @@
|
|||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.3.0",
|
||||||
"@types/nodemailer": "^6.4.21",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { type Client, createClient } from "@libsql/client";
|
import { type Client, createClient } from "@libsql/client";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
|
|||||||
import {
|
import {
|
||||||
ensureDataDirectory,
|
ensureDataDirectory,
|
||||||
ensureDefaultUser,
|
ensureDefaultUser,
|
||||||
getDataDir,
|
|
||||||
getDbPaths,
|
getDbPaths,
|
||||||
repairOrphanedDoseIds,
|
repairOrphanedDoseIds,
|
||||||
repairTrailingHyphenDoseIds,
|
repairTrailingHyphenDoseIds,
|
||||||
@@ -65,8 +63,8 @@ let client: Client;
|
|||||||
try {
|
try {
|
||||||
client = createClient({ url });
|
client = createClient({ url });
|
||||||
log.debug(`[DB] Database client created successfully`);
|
log.debug(`[DB] Database client created successfully`);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
|
||||||
log.error(`[DB] Database path: ${dbPath}`);
|
log.error(`[DB] Database path: ${dbPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-23
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
|||||||
writeFileSync(testFile, "test");
|
writeFileSync(testFile, "test");
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
return { success: false, error: err.message };
|
return { success: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
|
|||||||
try {
|
try {
|
||||||
await migrate(database, { migrationsFolder });
|
await migrate(database, { migrationsFolder });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// If the error is about existing schema objects, the DB is already up-to-date
|
// If the error is about existing schema objects, the DB is already up-to-date
|
||||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
// This happens when ALTER migrations in client.ts have already added the columns,
|
||||||
// or when tables were created before drizzle migrations were introduced
|
// or when tables were created before drizzle migrations were introduced
|
||||||
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
|
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
||||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
||||||
}
|
}
|
||||||
return { success: false, error: err.message };
|
return { success: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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`,
|
`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
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`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)
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
`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
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
@@ -140,6 +142,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
// Added for share stock visibility toggle
|
// Added for share stock visibility toggle
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
|
// Added for timeline visibility toggles (dashboard + shared schedule)
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||||
// Added for prescription refill tracking and reminders
|
// Added for prescription refill tracking and reminders
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||||
@@ -158,10 +164,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "duplicate column" errors - column already exists
|
// Silently ignore "duplicate column" errors - column already exists
|
||||||
if (!e.message?.includes("duplicate column")) {
|
if (!(e as Error).message?.includes("duplicate column")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,10 +188,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of createTableMigrations) {
|
for (const sql of createTableMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "table already exists" errors
|
// Silently ignore "table already exists" errors
|
||||||
if (!e.message?.includes("already exists")) {
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,10 +205,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of createIndexMigrations) {
|
for (const sql of createIndexMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "already exists" errors
|
// Silently ignore "already exists" errors
|
||||||
if (!e.message?.includes("already exists")) {
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,8 +233,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
|
|||||||
return true; // Created
|
return true; // Created
|
||||||
}
|
}
|
||||||
return false; // Already exists
|
return false; // Already exists
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error(`[DB] Error creating default user:`, e.message);
|
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +261,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
|
|||||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||||
);
|
);
|
||||||
repaired = result.rowsAffected;
|
repaired = result.rowsAffected;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
|
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repaired, errors };
|
return { repaired, errors };
|
||||||
@@ -379,14 +385,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
|||||||
args: [newDoseId, dose.id],
|
args: [newDoseId, dose.id],
|
||||||
});
|
});
|
||||||
repaired++;
|
repaired++;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Repair failed: ${e.message}`);
|
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repaired, errors };
|
return { repaired, errors };
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export async function executeMigration(
|
|||||||
const executed = Number(tables.rows[0].count) || 0;
|
const executed = Number(tables.rows[0].count) || 0;
|
||||||
|
|
||||||
return { success: true, executed, errors };
|
return { success: true, executed, errors };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
errors.push(err.message);
|
errors.push((err as Error).message);
|
||||||
return { success: false, executed: 0, errors };
|
return { success: false, executed: 0, errors };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] {
|
|||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
|
last_reminder_med_name text,
|
||||||
|
last_reminder_taken_by text,
|
||||||
|
last_stock_reminder_sent text,
|
||||||
|
last_stock_reminder_channel text,
|
||||||
|
last_stock_reminder_med_names text,
|
||||||
|
last_prescription_reminder_sent text,
|
||||||
|
last_prescription_reminder_channel text,
|
||||||
|
last_prescription_reminder_med_names text,
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// UI timeline visibility preferences
|
||||||
|
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
|
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
|
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
|
||||||
// Last notification tracking (intake reminders)
|
// Last notification tracking (intake reminders)
|
||||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||||
lastNotificationType: text("last_notification_type"),
|
lastNotificationType: text("last_notification_type"),
|
||||||
@@ -159,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
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'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
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
|
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 { existsSync } from "node:fs";
|
||||||
|
import type { IncomingHttpHeaders } from "node:http";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
@@ -20,6 +22,7 @@ import { medicationRoutes } from "./routes/medications.js";
|
|||||||
import { oidcRoutes } from "./routes/oidc.js";
|
import { oidcRoutes } from "./routes/oidc.js";
|
||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { refillRoutes } from "./routes/refills.js";
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
|
import { reportRoutes } from "./routes/report.js";
|
||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
@@ -44,6 +47,16 @@ import {
|
|||||||
parseCorsOrigins,
|
parseCorsOrigins,
|
||||||
} from "./utils/server-config.js";
|
} 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) */
|
/** Create and configure Fastify app (without starting) */
|
||||||
export async function createApp(options?: {
|
export async function createApp(options?: {
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
@@ -72,6 +85,13 @@ export async function createApp(options?: {
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: opts.logLevel },
|
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
|
// Build config
|
||||||
@@ -118,6 +138,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -139,6 +160,13 @@ const app = Fastify({
|
|||||||
logger: {
|
logger: {
|
||||||
level: env.LOG_LEVEL,
|
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);
|
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||||
@@ -190,6 +218,7 @@ await app.register(shareRoutes);
|
|||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// Re-throw our own errors
|
// Re-throw our own errors
|
||||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
|
||||||
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// JWT verification failed
|
// JWT verification failed
|
||||||
|
|||||||
+32
-35
@@ -1,4 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
@@ -8,6 +9,12 @@ import { getDataDir } from "../db/db-utils.js";
|
|||||||
import { refreshTokens, users } from "../db/schema.js";
|
import { refreshTokens, users } from "../db/schema.js";
|
||||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||||
import type { AuthUser } from "../types/fastify.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
|
// Argon2id Configuration - State of the Art Password Hashing
|
||||||
@@ -53,6 +60,7 @@ const sensitiveRateLimitConfig = {
|
|||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
|
.trim()
|
||||||
.min(3, "Username must be at least 3 characters")
|
.min(3, "Username must be at least 3 characters")
|
||||||
.max(50, "Username must be at most 50 characters")
|
.max(50, "Username must be at most 50 characters")
|
||||||
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
|
.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({
|
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"),
|
password: z.string().min(1, "Password is required"),
|
||||||
rememberMe: z.boolean().optional().default(false),
|
rememberMe: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
@@ -81,6 +89,8 @@ const updateProfileSchema = z.object({
|
|||||||
// Auth Routes
|
// Auth Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
|
|
||||||
// Token TTLs
|
// Token TTLs
|
||||||
const accessTtlMinutes = 15;
|
const accessTtlMinutes = 15;
|
||||||
const refreshTtlDays = 14;
|
const refreshTtlDays = 14;
|
||||||
@@ -461,36 +471,35 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const data = await request.file();
|
const data = await request.file();
|
||||||
if (!data) {
|
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
|
// Validate file type
|
||||||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||||
if (!allowedTypes.includes(data.mimetype)) {
|
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||||
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
let uploadBuffer: Buffer;
|
||||||
const ext = data.filename.split(".").pop() || "jpg";
|
try {
|
||||||
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
|
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
|
let filename: string;
|
||||||
const fs = await import("node:fs/promises");
|
try {
|
||||||
const path = await import("node:path");
|
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
|
||||||
const imagesDir = path.join(getDataDir(), "images");
|
} catch {
|
||||||
await fs.mkdir(imagesDir, { recursive: true });
|
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
|
||||||
|
}
|
||||||
const buffer = await data.toBuffer();
|
|
||||||
await fs.writeFile(path.join(imagesDir, filename), buffer);
|
|
||||||
|
|
||||||
// Delete old avatar if exists
|
// Delete old avatar if exists
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||||
if (user?.avatarUrl) {
|
if (user?.avatarUrl) {
|
||||||
try {
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
await fs.unlink(path.join(imagesDir, user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
@@ -521,13 +530,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
const fs = await import("node:fs/promises");
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
const path = await import("node:path");
|
|
||||||
try {
|
|
||||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user
|
// Update user
|
||||||
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
|
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
|
// Delete avatar file if exists
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
|
||||||
if (user?.avatarUrl) {
|
if (user?.avatarUrl) {
|
||||||
const fs = await import("node:fs/promises");
|
removeImageFiles(IMAGES_DIR, user.avatarUrl);
|
||||||
const path = await import("node:path");
|
|
||||||
try {
|
|
||||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
|
||||||
} catch {
|
|
||||||
// Ignore if file doesn't exist
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user - cascade delete handles all related data
|
// 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 type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
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 { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Validation Schemas
|
// 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"),
|
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
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -38,6 +46,91 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
return authUser.id;
|
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
|
// Dose Tracking Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -56,6 +149,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -94,6 +188,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: null, // Marked by the user themselves
|
markedBy: null, // Marked by the user themselves
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -213,9 +308,9 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected read for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +322,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -249,12 +345,20 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const { doseId } = parsed.data;
|
const { doseId } = parsed.data;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected mark for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
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
|
// Check if already marked
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -262,6 +366,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
request.log.debug(`[ShareDose] Duplicate mark ignored (owner=${share.userId}, doseId=${doseId})`);
|
||||||
return { success: true, message: "Already marked" };
|
return { success: true, message: "Already marked" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +375,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId: share.userId,
|
userId: share.userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: share.takenBy, // e.g. "Daniel"
|
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 };
|
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) => {
|
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
|
||||||
const { token, doseId } = request.params;
|
const { token, doseId } = request.params;
|
||||||
|
|
||||||
// Find share token
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected unmark for token ${maskToken(token)} (reason=${reason})`);
|
||||||
return reply.notFound("Share link not found");
|
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
|
// Check if this dose was dismissed
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -296,9 +414,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (existing?.dismissed) {
|
if (existing?.dismissed) {
|
||||||
// Already dismissed - keep the record as-is
|
// Already dismissed - keep the record as-is
|
||||||
|
request.log.debug(`[ShareDose] Unmark ignored for dismissed dose (owner=${share.userId}, doseId=${doseId})`);
|
||||||
} else {
|
} else {
|
||||||
// Not dismissed - delete the record entirely
|
// Not dismissed - delete the record entirely
|
||||||
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
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 };
|
return { success: true };
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { extname, resolve } from "node:path";
|
import { extname, resolve } from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.0";
|
const EXPORT_VERSION = "1.1";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
|
|||||||
packCount: z.number().int().min(0).default(1),
|
packCount: z.number().int().min(0).default(1),
|
||||||
blistersPerPack: z.number().int().min(1).default(1),
|
blistersPerPack: z.number().int().min(1).default(1),
|
||||||
pillsPerBlister: z.number().int().min(1).default(1),
|
pillsPerBlister: z.number().int().min(1).default(1),
|
||||||
|
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
||||||
@@ -60,6 +61,7 @@ const medicationExportSchema = z.object({
|
|||||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||||
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
||||||
prescriptionExpiryDate: z.string().nullable().optional(),
|
prescriptionExpiryDate: z.string().nullable().optional(),
|
||||||
|
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
|
||||||
image: z.string().nullable().optional(), // base64 data URL or null
|
image: z.string().nullable().optional(), // base64 data URL or null
|
||||||
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||||
});
|
});
|
||||||
@@ -70,10 +72,19 @@ const doseHistorySchema = z.object({
|
|||||||
scheduledTime: z.string(), // ISO datetime
|
scheduledTime: z.string(), // ISO datetime
|
||||||
takenAt: z.string(), // ISO datetime
|
takenAt: z.string(), // ISO datetime
|
||||||
markedBy: z.string().nullable().optional(),
|
markedBy: z.string().nullable().optional(),
|
||||||
|
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||||
dismissed: z.boolean().default(false),
|
dismissed: z.boolean().default(false),
|
||||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refillHistoryExportSchema = z.object({
|
||||||
|
medicationRef: z.string(), // References _exportId
|
||||||
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
usedPrescription: z.boolean().default(false),
|
||||||
|
refillDate: z.string(), // ISO datetime
|
||||||
|
});
|
||||||
|
|
||||||
const shareLinkSchema = z.object({
|
const shareLinkSchema = z.object({
|
||||||
takenBy: z.string().min(1),
|
takenBy: z.string().min(1),
|
||||||
scheduleDays: z.number().int().min(1).default(30),
|
scheduleDays: z.number().int().min(1).default(30),
|
||||||
@@ -106,9 +117,11 @@ const settingsExportSchema = z
|
|||||||
lowStockDays: z.number().int().default(30),
|
lowStockDays: z.number().int().default(30),
|
||||||
normalStockDays: z.number().int().default(90),
|
normalStockDays: z.number().int().default(90),
|
||||||
highStockDays: z.number().int().default(180),
|
highStockDays: z.number().int().default(180),
|
||||||
|
expiryWarningDays: z.number().int().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: z.string().default("en"),
|
language: z.string().default("en"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
|
shareStockStatus: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -118,6 +131,7 @@ const importDataSchema = z.object({
|
|||||||
includeSensitiveData: z.boolean().default(false),
|
includeSensitiveData: z.boolean().default(false),
|
||||||
medications: z.array(medicationExportSchema).default([]),
|
medications: z.array(medicationExportSchema).default([]),
|
||||||
doseHistory: z.array(doseHistorySchema).default([]),
|
doseHistory: z.array(doseHistorySchema).default([]),
|
||||||
|
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||||
settings: settingsExportSchema,
|
settings: settingsExportSchema,
|
||||||
shareLinks: z.array(shareLinkSchema).default([]),
|
shareLinks: z.array(shareLinkSchema).default([]),
|
||||||
});
|
});
|
||||||
@@ -127,7 +141,7 @@ const importDataSchema = z.object({
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
return getAnonymousUserId();
|
return getAnonymousUserId();
|
||||||
}
|
}
|
||||||
@@ -285,6 +299,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
packCount: med.packCount ?? 1,
|
packCount: med.packCount ?? 1,
|
||||||
blistersPerPack: med.blistersPerPack ?? 1,
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||||
|
totalPills: med.totalPills ?? null,
|
||||||
looseTablets: med.looseTablets ?? 0,
|
looseTablets: med.looseTablets ?? 0,
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: med.packageType ?? "blister",
|
||||||
@@ -303,6 +318,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||||
|
dismissedUntil: med.dismissedUntil ?? null,
|
||||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||||
};
|
};
|
||||||
@@ -349,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
scheduledTime: scheduledTimeIso,
|
scheduledTime: scheduledTimeIso,
|
||||||
takenAt: takenAtIso,
|
takenAt: takenAtIso,
|
||||||
markedBy: dose.markedBy,
|
markedBy: dose.markedBy,
|
||||||
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenByPerson: parsed.person,
|
takenByPerson: parsed.person,
|
||||||
};
|
};
|
||||||
@@ -380,8 +397,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
|
expiryWarningDays: settings.expiryWarningDays,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode,
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
shareStockStatus: settings.shareStockStatus,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -412,6 +431,39 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. Load refill history
|
||||||
|
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
|
|
||||||
|
const exportRefillHistory = refills
|
||||||
|
.map((refill) => {
|
||||||
|
const exportId = medIdToExportId.get(refill.medicationId);
|
||||||
|
if (!exportId) return null; // Orphaned refill, skip
|
||||||
|
|
||||||
|
// Safely convert refillDate to ISO string
|
||||||
|
let refillDateIso: string;
|
||||||
|
try {
|
||||||
|
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||||
|
refillDateIso = refill.refillDate.toISOString();
|
||||||
|
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||||
|
const d = new Date(refill.refillDate);
|
||||||
|
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
refillDateIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
refillDateIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationRef: exportId,
|
||||||
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
|
refillDate: refillDateIso,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
// Build export object
|
// Build export object
|
||||||
const exportData = {
|
const exportData = {
|
||||||
version: EXPORT_VERSION,
|
version: EXPORT_VERSION,
|
||||||
@@ -419,12 +471,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
includeSensitiveData: includeSensitive,
|
includeSensitiveData: includeSensitive,
|
||||||
medications: exportMedications,
|
medications: exportMedications,
|
||||||
doseHistory: exportDoseHistory,
|
doseHistory: exportDoseHistory,
|
||||||
|
refillHistory: exportRefillHistory,
|
||||||
settings: exportSettings,
|
settings: exportSettings,
|
||||||
shareLinks: exportShareLinks,
|
shareLinks: exportShareLinks,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set download headers
|
// Set download headers
|
||||||
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||||
|
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||||
|
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||||
|
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||||
reply.header("Content-Type", "application/json");
|
reply.header("Content-Type", "application/json");
|
||||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
@@ -475,7 +532,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete in order: doses, share tokens, medications, settings
|
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||||
|
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||||
await db.delete(medications).where(eq(medications.userId, userId));
|
await db.delete(medications).where(eq(medications.userId, userId));
|
||||||
@@ -517,6 +575,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
looseTablets: med.inventory.looseTablets,
|
looseTablets: med.inventory.looseTablets,
|
||||||
|
totalPills: med.inventory.totalPills ?? null,
|
||||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
@@ -536,6 +595,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||||
|
dismissedUntil: med.dismissedUntil || null,
|
||||||
imageUrl: null, // Will be set after image is saved
|
imageUrl: null, // Will be set after image is saved
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -567,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -594,8 +655,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||||
highStockDays: importData.settings.highStockDays ?? 180,
|
highStockDays: importData.settings.highStockDays ?? 180,
|
||||||
|
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||||
language: importData.settings.language ?? "en",
|
language: importData.settings.language ?? "en",
|
||||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||||
|
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,11 +676,27 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Import refill history with remapped medication IDs
|
||||||
|
for (const refill of importData.refillHistory) {
|
||||||
|
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||||
|
if (!newMedId) continue; // Skip orphaned refill records
|
||||||
|
|
||||||
|
await db.insert(refillHistory).values({
|
||||||
|
medicationId: newMedId,
|
||||||
|
userId,
|
||||||
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
|
refillDate: new Date(refill.refillDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imported: {
|
imported: {
|
||||||
medications: importData.medications.length,
|
medications: importData.medications.length,
|
||||||
doseHistory: importData.doseHistory.length,
|
doseHistory: importData.doseHistory.length,
|
||||||
|
refillHistory: importData.refillHistory.length,
|
||||||
settings: importData.settings ? 1 : 0,
|
settings: importData.settings ? 1 : 0,
|
||||||
shareLinks: importData.shareLinks.length,
|
shareLinks: importData.shareLinks.length,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { createWriteStream, existsSync, unlinkSync } from "node:fs";
|
import { resolve } from "node:path";
|
||||||
import { extname, resolve } from "node:path";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import { and, eq, like } from "drizzle-orm";
|
import { and, eq, like } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.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 { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.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";
|
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
@@ -623,9 +627,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
||||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
|
||||||
"/medications/:id/stock-adjustment",
|
"/medications/:id/stock-adjustment",
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
@@ -640,16 +644,32 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
||||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
if (
|
||||||
|
looseTablets !== undefined &&
|
||||||
|
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: {
|
||||||
|
stockAdjustment: number;
|
||||||
|
lastStockCorrectionAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
looseTablets?: number;
|
||||||
|
} = {
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (looseTablets !== undefined) {
|
||||||
|
updateFields.looseTablets = looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(medications)
|
.update(medications)
|
||||||
.set({
|
.set(updateFields)
|
||||||
stockAdjustment,
|
|
||||||
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -677,10 +697,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
if (existing.imageUrl) {
|
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||||
const imagePath = resolve(IMAGES_DIR, existing.imageUrl);
|
|
||||||
if (existsSync(imagePath)) unlinkSync(imagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await db
|
const deleted = await db
|
||||||
.delete(medications)
|
.delete(medications)
|
||||||
@@ -703,24 +720,31 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
const data = await req.file();
|
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 (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
|
||||||
if (!allowedTypes.includes(data.mimetype)) {
|
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
|
||||||
return reply.badRequest("Invalid file type. Allowed: JPEG, PNG, WebP, GIF");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = extname(data.filename) || ".jpg";
|
let uploadBuffer: Buffer;
|
||||||
const filename = `med-${idNum}-${Date.now()}${ext}`;
|
try {
|
||||||
const filepath = resolve(IMAGES_DIR, filename);
|
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
|
// Delete old image if exists
|
||||||
if (existing.imageUrl) {
|
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||||
const oldPath = resolve(IMAGES_DIR, existing.imageUrl);
|
|
||||||
if (existsSync(oldPath)) unlinkSync(oldPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(medications)
|
.update(medications)
|
||||||
@@ -742,10 +766,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
if (existing.imageUrl) {
|
if (existing.imageUrl) removeImageFiles(IMAGES_DIR, existing.imageUrl);
|
||||||
const filepath = resolve(IMAGES_DIR, existing.imageUrl);
|
|
||||||
if (existsSync(filepath)) unlinkSync(filepath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(medications)
|
.update(medications)
|
||||||
@@ -776,26 +797,38 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.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
|
// Get all taken doses for this user to calculate actual consumption
|
||||||
const takenDoses = await db
|
const takenDoses = await db
|
||||||
.select()
|
.select()
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
// Create a map of medication ID to taken dose count
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
takenDoses.forEach((dose) => {
|
takenDoses.forEach((dose) => {
|
||||||
const parts = dose.doseId.split("-");
|
const parts = dose.doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length < 3) return;
|
||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
if (Number.isNaN(medId)) return;
|
||||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
|
||||||
if (!takenDosesMap.has(medId)) {
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
takenDosesMap.set(medId, []);
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
}
|
|
||||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
|
||||||
}
|
}
|
||||||
|
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
|
// Use current time as the reference point for "available" stock
|
||||||
@@ -822,69 +855,109 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
? looseTablets + stockAdjustment
|
? looseTablets + stockAdjustment
|
||||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||||
// 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
|
|
||||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
// 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)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count consumed pills by generating expected doses and checking if they're taken
|
// Count consumed pills by generating expected doses and checking if they're taken
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
const msPerDay = 86400000;
|
const msPerDay = 86400000;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
blisters.forEach((blister, blisterIdx) => {
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
const blisterStart = parseLocalDateTime(blister.start);
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
if (Number.isNaN(blisterStart.getTime())) return;
|
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;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
effectiveStart = stockCorrectionCutoff + period;
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
} else {
|
} else {
|
||||||
effectiveStart = blisterStart.getTime();
|
effectiveStart = blisterStart;
|
||||||
}
|
}
|
||||||
if (effectiveStart > now.getTime()) return;
|
|
||||||
|
|
||||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
|
||||||
|
|
||||||
// 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 intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const peopleForThisIntake: (string | null)[] = intakePerson
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
? [intakePerson]
|
let peopleForThisIntake: Array<string | null>;
|
||||||
: takenByJson.length > 0
|
if (intakePerson) {
|
||||||
? takenByJson
|
peopleForThisIntake = [intakePerson];
|
||||||
: [null];
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
// Generate expected dose IDs and check if they're taken
|
let timeBasedConsumed = 0;
|
||||||
for (let i = 0; i < occurrences; i++) {
|
let lastAutoConsumedDateMs = 0;
|
||||||
const doseDate = new Date(effectiveStart + i * period);
|
|
||||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
|
||||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
|
||||||
|
|
||||||
// Check if each person has taken this dose
|
if (effectiveStart <= now.getTime()) {
|
||||||
for (const person of peopleForThisIntake) {
|
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
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;
|
consumedUntilNow += blister.usage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||||
|
|
||||||
@@ -930,6 +1003,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationId: row.id,
|
medicationId: row.id,
|
||||||
medicationName: row.name,
|
medicationName: row.name,
|
||||||
totalPills: currentStock,
|
totalPills: currentStock,
|
||||||
|
currentPills: currentStock,
|
||||||
plannerUsage: usageTotal,
|
plannerUsage: usageTotal,
|
||||||
blisterSize: pillsPerBlister,
|
blisterSize: pillsPerBlister,
|
||||||
blistersNeeded,
|
blistersNeeded,
|
||||||
|
|||||||
+18
-12
@@ -63,7 +63,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /auth/oidc/login - Initiates OIDC flow
|
// GET /auth/oidc/login - Initiates OIDC flow
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/auth/oidc/login", async (_request, reply) => {
|
app.get("/auth/oidc/login", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const config = await getOIDCConfig();
|
const config = await getOIDCConfig();
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return reply.redirect(authUrl.href);
|
return reply.redirect(authUrl.href);
|
||||||
} catch (err: any) {
|
} 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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -120,7 +120,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Handle OIDC provider errors
|
// Handle OIDC provider errors
|
||||||
if (error) {
|
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}`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +131,14 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Verify state
|
// Verify state
|
||||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||||
if (!storedState.valid || storedState.value !== 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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get code verifier
|
// Get code verifier
|
||||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Get user info
|
// Get user info
|
||||||
const sub = tokens.claims()?.sub;
|
const sub = tokens.claims()?.sub;
|
||||||
if (!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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||||
}
|
}
|
||||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||||
@@ -167,11 +167,17 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Extract username from configured claim
|
// Extract username from configured claim
|
||||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||||
const username =
|
const username =
|
||||||
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
(userInfo as Record<string, string>)[usernameClaim] ||
|
||||||
|
userInfo.preferred_username ||
|
||||||
|
userInfo.email ||
|
||||||
|
userInfo.sub;
|
||||||
const oidcSubject = userInfo.sub;
|
const oidcSubject = userInfo.sub;
|
||||||
|
|
||||||
if (!username || !oidcSubject) {
|
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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +216,8 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// In dev: CORS_ORIGINS contains the frontend URL
|
// In dev: CORS_ORIGINS contains the frontend URL
|
||||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||||
} catch (err: any) {
|
} 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`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,7 +258,7 @@ async function findOrCreateOIDCUser(
|
|||||||
|
|
||||||
// Check if auto-create is enabled
|
// Check if auto-create is enabled
|
||||||
if (!env.OIDC_AUTO_CREATE_USERS) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -509,8 +509,10 @@ ${getFooterPlain(language)}`;
|
|||||||
const buildTableRow = (row: LowStockItem) => {
|
const buildTableRow = (row: LowStockItem) => {
|
||||||
const isEmpty = row.medsLeft <= 0;
|
const isEmpty = row.medsLeft <= 0;
|
||||||
const isCritical = row.isCritical !== false;
|
const isCritical = row.isCritical !== false;
|
||||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||||
|
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
const safeName = escapeHtml(row.name);
|
const safeName = escapeHtml(row.name);
|
||||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||||
@@ -586,7 +588,7 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
||||||
@@ -603,7 +605,8 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
// Update the reminder state to record this notification was sent
|
// Update the reminder state to record this notification was sent
|
||||||
if (results.email || results.push) {
|
if (results.email || results.push) {
|
||||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
const singleChannel = results.email ? "email" : "push";
|
||||||
|
const channel = results.email && results.push ? "both" : singleChannel;
|
||||||
updateReminderSentTime("stock", channel);
|
updateReminderSentTime("stock", channel);
|
||||||
|
|
||||||
// Also update user settings in database so frontend can display the info
|
// Also update user settings in database so frontend can display the info
|
||||||
@@ -700,14 +703,15 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
const bodyText =
|
const bodyText =
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||||
const alertText =
|
const emptyAlert =
|
||||||
emptyRx.length > 0
|
emptyRx.length === 1
|
||||||
? emptyRx.length === 1
|
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
: lowRx.length === 1
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
const tableRows = filteredPrescriptionLow
|
const tableRows = filteredPrescriptionLow
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -807,7 +811,7 @@ ${getFooterPlain(language)}`;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
||||||
@@ -823,7 +827,8 @@ ${getFooterPlain(language)}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (results.email || results.push) {
|
if (results.email || results.push) {
|
||||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
const singleChannel = results.email ? "email" : "push";
|
||||||
|
const channel = results.email && results.push ? "both" : singleChannel;
|
||||||
updateReminderSentTime("prescription", channel);
|
updateReminderSentTime("prescription", channel);
|
||||||
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,23 +52,37 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
if (!med) return reply.notFound("Medication not found");
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||||
|
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||||
|
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
||||||
|
const effectiveLoosePillsAdded = loosePillsAdded;
|
||||||
|
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||||
|
|
||||||
|
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||||
|
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||||
|
}
|
||||||
|
|
||||||
if (usePrescription) {
|
if (usePrescription) {
|
||||||
if (!(med.prescriptionEnabled ?? false)) {
|
if (!(med.prescriptionEnabled ?? false)) {
|
||||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||||
}
|
}
|
||||||
const remaining = med.prescriptionRemainingRefills ?? 0;
|
if (remainingPrescriptionRefills <= 0) {
|
||||||
if (remaining <= 0) {
|
|
||||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||||
}
|
}
|
||||||
|
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||||
|
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update medication stock
|
// Update medication stock
|
||||||
const newPackCount = med.packCount + packsAdded;
|
const newPackCount = med.packCount + effectivePacksAdded;
|
||||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
|
let consumedRefills = 0;
|
||||||
|
if (usePrescription) {
|
||||||
|
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||||
|
}
|
||||||
const newRemainingRefills = usePrescription
|
const newRemainingRefills = usePrescription
|
||||||
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -77,8 +91,6 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
stockAdjustment: 0, // Reset offset since we're adding to base stock
|
|
||||||
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
@@ -89,16 +101,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
.values({
|
.values({
|
||||||
medicationId: medId,
|
medicationId: medId,
|
||||||
userId,
|
userId,
|
||||||
packsAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
usedPrescription: usePrescription,
|
usedPrescription: usePrescription,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Calculate pills added for response (packageType-aware)
|
// Calculate pills added for response (packageType-aware)
|
||||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
|
const totalPillsAdded = isBottle
|
||||||
|
? effectiveLoosePillsAdded
|
||||||
|
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||||
const newTotalPills = isBottle
|
const newTotalPills = isBottle
|
||||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
? newLooseTablets + (med.stockAdjustment ?? 0)
|
||||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||||
@@ -107,8 +120,8 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
success: true,
|
success: true,
|
||||||
refill: {
|
refill: {
|
||||||
id: refill.id,
|
id: refill.id,
|
||||||
packsAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
totalPillsAdded,
|
totalPillsAdded,
|
||||||
refillDate: refill.refillDate,
|
refillDate: refill.refillDate,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { doseTracking, medications, refillHistory } from "../db/schema.js";
|
||||||
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
|
const reportDataSchema = z.object({
|
||||||
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return getAnonymousUserId();
|
||||||
|
}
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
throw new Error("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
||||||
|
app.post("/medications/report-data", async (req, reply) => {
|
||||||
|
const parsed = reportDataSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
const { medicationIds } = parsed.data;
|
||||||
|
|
||||||
|
// Verify all medications belong to this user
|
||||||
|
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||||
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
|
for (const id of medicationIds) {
|
||||||
|
if (!userMedIds.has(id)) {
|
||||||
|
return reply.status(403).send({ error: "Access denied to medication" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch dose tracking for all requested medications
|
||||||
|
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||||
|
const allDoses = await db
|
||||||
|
.select({
|
||||||
|
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; 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,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch refill history for requested medications
|
||||||
|
const result: Record<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
|
dosesDismissed: number;
|
||||||
|
firstDoseAt: string | null;
|
||||||
|
lastDoseAt: string | null;
|
||||||
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Get refills for this medication
|
||||||
|
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
|
||||||
|
|
||||||
|
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,
|
||||||
|
refills: refills.map((r) => ({
|
||||||
|
packsAdded: r.packsAdded,
|
||||||
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
@@ -33,6 +33,9 @@ export type UserSettings = {
|
|||||||
language: Language;
|
language: Language;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
shareStockStatus: boolean;
|
shareStockStatus: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
lastAutoEmailSent: string | null;
|
lastAutoEmailSent: string | null;
|
||||||
lastNotificationType: string | null;
|
lastNotificationType: string | null;
|
||||||
lastNotificationChannel: string | null;
|
lastNotificationChannel: string | null;
|
||||||
@@ -69,6 +72,9 @@ type SettingsBody = {
|
|||||||
language: string;
|
language: string;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
shareStockStatus: boolean;
|
shareStockStatus: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestEmailBody = {
|
type TestEmailBody = {
|
||||||
@@ -119,6 +125,9 @@ function getDefaultSettings() {
|
|||||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||||
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
||||||
|
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
||||||
|
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
||||||
|
swapDashboardMainSections: false,
|
||||||
lastAutoEmailSent: null,
|
lastAutoEmailSent: null,
|
||||||
lastNotificationType: null,
|
lastNotificationType: null,
|
||||||
lastNotificationChannel: null,
|
lastNotificationChannel: null,
|
||||||
@@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
|||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
|||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -239,7 +254,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
// If auth is disabled, use the anonymous user
|
// If auth is disabled, use the anonymous user
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
return getAnonymousUserId();
|
return getAnonymousUserId();
|
||||||
@@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
// SMTP settings (from .env - shared/server-configured)
|
// SMTP settings (from .env - shared/server-configured)
|
||||||
smtpHost: process.env.SMTP_HOST ?? "",
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
||||||
@@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
language: body.language ?? "en",
|
language: body.language ?? "en",
|
||||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: body.shareStockStatus ?? true,
|
shareStockStatus: body.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -544,7 +565,7 @@ export async function sendShoutrrrNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
const method = "POST";
|
const method = "POST";
|
||||||
|
|||||||
+42
-12
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@@ -14,9 +14,6 @@ import {
|
|||||||
personTakesMedication,
|
personTakesMedication,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// Share token validity: 1 year in milliseconds
|
|
||||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Validation Schemas
|
// Validation Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -25,6 +22,11 @@ const createShareSchema = z.object({
|
|||||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
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
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -54,6 +56,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Find share token
|
// Find share token
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
|
||||||
return reply.status(404).send({
|
return reply.status(404).send({
|
||||||
error: "Share link not found",
|
error: "Share link not found",
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -62,6 +65,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Check if token has expired
|
// Check if token has expired
|
||||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
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
|
// 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));
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
return reply.status(410).send({
|
return reply.status(410).send({
|
||||||
@@ -154,6 +160,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
shareStockStatus: settings?.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,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");
|
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({
|
await db.insert(shareTokens).values({
|
||||||
userId: userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
takenBy,
|
takenBy,
|
||||||
scheduleDays,
|
scheduleDays,
|
||||||
expiresAt,
|
expiresAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[Share] Created new share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
reused: false,
|
||||||
token,
|
token,
|
||||||
shareUrl: `/share/${token}`,
|
shareUrl: `/share/${token}`,
|
||||||
expiresAt: expiresAt.toISOString(),
|
expiresAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
getTimezone,
|
getTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
type Intake,
|
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -51,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
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(
|
async function sendIntakeReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
intakes: UpcomingIntake[],
|
intakes: UpcomingIntake[],
|
||||||
@@ -247,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
`[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)
|
// Check if any intake reminder notifications are enabled (granular check)
|
||||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||||
@@ -263,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// 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);
|
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
@@ -281,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
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)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
@@ -321,7 +430,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
||||||
@@ -684,7 +793,8 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
saveIntakeReminderState(state);
|
saveIntakeReminderState(state);
|
||||||
|
|
||||||
// Update global reminder state for UI display
|
// Update global reminder state for UI display
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
updateReminderSentTime("intake", channel);
|
updateReminderSentTime("intake", channel);
|
||||||
|
|
||||||
// Also update user settings in database so frontend can display the info
|
// Also update user settings in database so frontend can display the info
|
||||||
|
|||||||
@@ -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 { resolve } from "node:path";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.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 { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
parseBlisters,
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
|
parseTakenByJson,
|
||||||
type ReminderState,
|
type ReminderState,
|
||||||
} from "../utils/scheduler-utils.js";
|
} 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 REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
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 {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
@@ -119,10 +171,6 @@ export async function updateUserReminderSentTime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
|
||||||
return parseBlisters(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
@@ -142,7 +190,8 @@ async function getMedicationsNeedingReminder(
|
|||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
language: Language
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
): Promise<LowStockItem[]> {
|
): Promise<LowStockItem[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -150,15 +199,152 @@ async function getMedicationsNeedingReminder(
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.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 lowStock: LowStockItem[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const intakes = parseIntakesJson(
|
||||||
const totalPills =
|
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.packageType ?? "blister") === "bottle"
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + 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;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
@@ -168,7 +354,7 @@ async function getMedicationsNeedingReminder(
|
|||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
medsLeft: totalPills,
|
medsLeft: currentPills,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
isCritical,
|
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(
|
async function sendReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
lowStock: LowStockItem[],
|
lowStock: LowStockItem[],
|
||||||
@@ -275,8 +480,10 @@ async function sendReminderEmail(
|
|||||||
.map((row) => {
|
.map((row) => {
|
||||||
const isEmpty = row.medsLeft <= 0;
|
const isEmpty = row.medsLeft <= 0;
|
||||||
const isCritical = row.isCritical;
|
const isCritical = row.isCritical;
|
||||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||||
|
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||||
@@ -329,7 +536,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
|
|||||||
---
|
---
|
||||||
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||||
|
|
||||||
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
|
const pluralSuffix = language === "de" ? "e" : "s";
|
||||||
|
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
||||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -359,6 +567,15 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
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
|
// Get all user settings to iterate over each user
|
||||||
const allUserSettings = await getAllUserSettings();
|
const allUserSettings = await getAllUserSettings();
|
||||||
|
|
||||||
@@ -400,12 +617,18 @@ async function checkAndSendReminderForUser(
|
|||||||
settings.userId,
|
settings.userId,
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
language
|
language,
|
||||||
|
settings.stockCalculationMode
|
||||||
);
|
);
|
||||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||||
|
|
||||||
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
|
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||||
|
if (!stockSendLock) {
|
||||||
|
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||||
);
|
);
|
||||||
@@ -460,7 +683,7 @@ async function checkAndSendReminderForUser(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -470,25 +693,37 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
const currentState = loadReminderState();
|
const currentState = loadReminderState();
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
saveReminderState({
|
saveReminderState({
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
lastAutoEmailDate: today,
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
lastNotificationType: "stock",
|
lastNotificationType: "stock",
|
||||||
lastNotificationChannel: channel,
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstMed = allLowStock[0];
|
|
||||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(stockSendLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
|
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||||
|
if (!prescriptionSendLock) {
|
||||||
|
logger.debug(
|
||||||
|
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||||
);
|
);
|
||||||
@@ -536,15 +771,18 @@ async function checkAndSendReminderForUser(
|
|||||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||||
|
|
||||||
const bodyText =
|
const bodyText =
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
|
||||||
const alertText =
|
|
||||||
emptyRx.length > 0
|
emptyRx.length > 0
|
||||||
? emptyRx.length === 1
|
? tr.prescriptionReminder.descriptionEmpty
|
||||||
|
: tr.prescriptionReminder.descriptionLow;
|
||||||
|
const emptyAlert =
|
||||||
|
emptyRx.length === 1
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
: lowRx.length === 1
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
const tableRows = allPrescriptionLow
|
const tableRows = allPrescriptionLow
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -649,7 +887,7 @@ async function checkAndSendReminderForUser(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -659,25 +897,31 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
const currentState = loadReminderState();
|
const currentState = loadReminderState();
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
saveReminderState({
|
saveReminderState({
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
lastAutoEmailDate: today,
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
lastNotificationType: "prescription",
|
lastNotificationType: "prescription",
|
||||||
lastNotificationChannel: channel,
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstMed = allPrescriptionLow[0];
|
|
||||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(prescriptionSendLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||||
|
let schedulerStarted = false;
|
||||||
|
|
||||||
function scheduleNextCheck(logger: ServiceLogger): void {
|
function scheduleNextCheck(logger: ServiceLogger): void {
|
||||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||||
@@ -702,6 +946,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startReminderScheduler(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()})...`);
|
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||||
|
|
||||||
// Check if we need to run immediately (missed today's check)
|
// Check if we need to run immediately (missed today's check)
|
||||||
@@ -709,9 +958,10 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
const today = getTodayInTimezone();
|
const today = getTodayInTimezone();
|
||||||
const currentHour = getCurrentHourInTimezone();
|
const currentHour = getCurrentHourInTimezone();
|
||||||
|
|
||||||
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run immediately
|
// If it's past REMINDER_HOUR today in the configured timezone and we haven't checked today, run one catch-up.
|
||||||
if (currentHour >= REMINDER_HOUR && state.lastAutoEmailDate !== today) {
|
// This is intentionally a single current-state snapshot (no replay of missed days).
|
||||||
logger.info("[Reminder] Missed today's check, running now...");
|
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}`));
|
checkAndSendReminder(logger).catch((err) => logger.error(`[Reminder] Error: ${err}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,9 +971,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
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 {
|
export function stopReminderScheduler(): void {
|
||||||
if (schedulerTimeout) {
|
if (schedulerTimeout) {
|
||||||
clearTimeout(schedulerTimeout);
|
clearTimeout(schedulerTimeout);
|
||||||
schedulerTimeout = null;
|
schedulerTimeout = null;
|
||||||
}
|
}
|
||||||
|
schedulerStarted = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
expect(response.json().code).toBe("VALIDATION_ERROR");
|
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 () => {
|
it("should reject invalid username characters", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -294,8 +345,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
|
|
||||||
// Should set cookies
|
// Should set cookies
|
||||||
const cookies = response.cookies;
|
const cookies = response.cookies;
|
||||||
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
|
||||||
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should login case-insensitively with different username casing", async () => {
|
it("should login case-insensitively with different username casing", async () => {
|
||||||
@@ -341,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
expect(response.json().code).toBe("INVALID_CREDENTIALS");
|
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 () => {
|
it("should support rememberMe option", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -393,7 +473,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -456,7 +536,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -506,7 +586,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -604,7 +684,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -653,7 +733,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -689,7 +769,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -742,7 +822,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
// Delete account
|
// Delete account
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type ClientTestOptions = {
|
||||||
|
dirWritable?: boolean;
|
||||||
|
authEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDbClientModule(options: ClientTestOptions = {}) {
|
||||||
|
const { dirWritable = true, authEnabled = false } = options;
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
process.env.AUTH_ENABLED = authEnabled ? "true" : "false";
|
||||||
|
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||||
|
|
||||||
|
const existsSync = vi.fn().mockReturnValue(false);
|
||||||
|
const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 });
|
||||||
|
vi.doMock("node:fs", () => ({ existsSync, statSync }));
|
||||||
|
|
||||||
|
const dotenvConfig = vi.fn();
|
||||||
|
vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } }));
|
||||||
|
|
||||||
|
const createClient = vi.fn().mockReturnValue({ execute: vi.fn() });
|
||||||
|
vi.doMock("@libsql/client", () => ({ createClient }));
|
||||||
|
|
||||||
|
const drizzle = vi.fn().mockReturnValue({ __db: true });
|
||||||
|
vi.doMock("drizzle-orm/libsql", () => ({ drizzle }));
|
||||||
|
|
||||||
|
const ensureDataDirectory = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||||
|
const getDbPaths = vi.fn().mockReturnValue({
|
||||||
|
dataDir: "/tmp/medassist-data",
|
||||||
|
dbPath: "/tmp/medassist-data/medassist.db",
|
||||||
|
url: "file:/tmp/medassist-data/medassist.db",
|
||||||
|
});
|
||||||
|
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||||
|
const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||||
|
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||||
|
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
vi.doMock("../db/db-utils.js", () => ({
|
||||||
|
buildDbUrl: vi.fn(),
|
||||||
|
getDataDir: vi.fn(),
|
||||||
|
ensureDataDirectory,
|
||||||
|
getDbPaths,
|
||||||
|
runDrizzleMigrations,
|
||||||
|
runAlterMigrations,
|
||||||
|
repairTrailingHyphenDoseIds,
|
||||||
|
repairOrphanedDoseIds,
|
||||||
|
ensureDefaultUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.doMock("../utils/logger.js", () => ({ log }));
|
||||||
|
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
const modulePromise = import("../db/client.js");
|
||||||
|
|
||||||
|
return {
|
||||||
|
modulePromise,
|
||||||
|
mocks: {
|
||||||
|
existsSync,
|
||||||
|
statSync,
|
||||||
|
dotenvConfig,
|
||||||
|
createClient,
|
||||||
|
drizzle,
|
||||||
|
ensureDataDirectory,
|
||||||
|
getDbPaths,
|
||||||
|
runDrizzleMigrations,
|
||||||
|
runAlterMigrations,
|
||||||
|
repairTrailingHyphenDoseIds,
|
||||||
|
repairOrphanedDoseIds,
|
||||||
|
ensureDefaultUser,
|
||||||
|
log,
|
||||||
|
exitSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("db/client bootstrap", () => {
|
||||||
|
it("initializes db and runs migrations when directory is writable", async () => {
|
||||||
|
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false });
|
||||||
|
const mod = await modulePromise;
|
||||||
|
|
||||||
|
expect(mod.db).toBeTruthy();
|
||||||
|
expect(mod.migrationsReady).toBeInstanceOf(Promise);
|
||||||
|
await mod.migrationsReady;
|
||||||
|
|
||||||
|
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||||
|
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
||||||
|
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes auth-enabled flag to ensureDefaultUser", async () => {
|
||||||
|
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true });
|
||||||
|
const mod = await modulePromise;
|
||||||
|
await mod.migrationsReady;
|
||||||
|
|
||||||
|
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when data directory is not writable", async () => {
|
||||||
|
const { modulePromise } = await loadDbClientModule({ dirWritable: false });
|
||||||
|
await expect(modulePromise).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -271,7 +271,7 @@ describe("Dose Tracking API", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.doses).toHaveLength(2);
|
expect(data.doses).toHaveLength(2);
|
||||||
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||||
// Each dose should have a takenAt timestamp
|
// Each dose should have a takenAt timestamp
|
||||||
for (const dose of data.doses) {
|
for (const dose of data.doses) {
|
||||||
expect(dose.takenAt).toBeTypeOf("number");
|
expect(dose.takenAt).toBeTypeOf("number");
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
|
|||||||
const { settingsRoutes } = await import("../routes/settings.js");
|
const { settingsRoutes } = await import("../routes/settings.js");
|
||||||
const { healthRoutes } = await import("../routes/health.js");
|
const { healthRoutes } = await import("../routes/health.js");
|
||||||
const { refillRoutes } = await import("../routes/refills.js");
|
const { refillRoutes } = await import("../routes/refills.js");
|
||||||
|
const { reportRoutes } = await import("../routes/report.js");
|
||||||
const { exportRoutes } = await import("../routes/export.js");
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -137,6 +138,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
@@ -167,6 +171,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -261,11 +266,80 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
await app.ready();
|
await app.ready();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Report Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Real /medications/report-data route", () => {
|
||||||
|
it("should return 400 for invalid payload", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when requested medication is not owned by user", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [999999] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json().error).toBe("Access denied to medication");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should aggregate taken/dismissed doses and refill history", async () => {
|
||||||
|
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||||
|
|
||||||
|
// One taken dose and one dismissed dose for the same medication
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-1735344000000`, 1735344000],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 1)`,
|
||||||
|
args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400],
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 2, 5, 1, 1735516800],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data[medId].dosesTaken).toBe(1);
|
||||||
|
expect(data[medId].dosesDismissed).toBe(1);
|
||||||
|
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
|
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
|
expect(data[medId].refills).toHaveLength(1);
|
||||||
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
|
packsAdded: 2,
|
||||||
|
loosePillsAdded: 5,
|
||||||
|
usedPrescription: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
testClient.close();
|
testClient.close();
|
||||||
@@ -744,6 +818,39 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const data = getResponse.json();
|
const data = getResponse.json();
|
||||||
expect(data.repeatDailyReminders).toBe(false);
|
expect(data.repeatDailyReminders).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject invalid language in lightweight language endpoint", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "fr" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("Invalid language");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create and update language via lightweight language endpoint", async () => {
|
||||||
|
let response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "de" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "en" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(getResponse.json().language).toBe("en");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1671,7 +1778,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
expect(medsResponse.statusCode).toBe(200);
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
const med = medsResponse.json().find((m: any) => m.id === medId);
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.prescriptionRemainingRefills).toBe(1);
|
expect(med.prescriptionRemainingRefills).toBe(1);
|
||||||
|
|
||||||
const historyResponse = await app.inject({
|
const historyResponse = await app.inject({
|
||||||
@@ -1809,8 +1916,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const refills = response.json();
|
const refills = response.json();
|
||||||
expect(refills).toHaveLength(2);
|
expect(refills).toHaveLength(2);
|
||||||
// Check both refills exist (order may vary)
|
// Check both refills exist (order may vary)
|
||||||
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||||
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
|
const hasLooseRefill = refills.some(
|
||||||
|
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
|
||||||
|
);
|
||||||
expect(hasPackRefill).toBe(true);
|
expect(hasPackRefill).toBe(true);
|
||||||
expect(hasLooseRefill).toBe(true);
|
expect(hasLooseRefill).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1888,7 +1997,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(getResponse.statusCode).toBe(200);
|
expect(getResponse.statusCode).toBe(200);
|
||||||
const meds = getResponse.json();
|
const meds = getResponse.json();
|
||||||
const med = meds.find((m: any) => m.id === medId);
|
const med = meds.find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med).toBeDefined();
|
expect(med).toBeDefined();
|
||||||
expect(med.stockAdjustment).toBe(-7);
|
expect(med.stockAdjustment).toBe(-7);
|
||||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||||
@@ -1934,7 +2043,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = getResponse.json().find((m: any) => m.id === medId);
|
const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.name).toBe("Renamed Med");
|
expect(med.name).toBe("Renamed Med");
|
||||||
expect(med.stockAdjustment).toBe(-5);
|
expect(med.stockAdjustment).toBe(-5);
|
||||||
});
|
});
|
||||||
@@ -2003,7 +2112,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// Verify adjustment is set
|
// Verify adjustment is set
|
||||||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
let med = getMeds.json().find((m: any) => m.id === medId);
|
let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.stockAdjustment).toBe(-10);
|
expect(med.stockAdjustment).toBe(-10);
|
||||||
|
|
||||||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||||||
@@ -2022,7 +2131,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// stockAdjustment should be reset to 0
|
// stockAdjustment should be reset to 0
|
||||||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
med = getMeds.json().find((m: any) => m.id === medId);
|
med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -2066,7 +2175,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// stockAdjustment should be preserved
|
// stockAdjustment should be preserved
|
||||||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
const med = getMeds.json().find((m: any) => m.id === medId);
|
const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.name).toBe("Renamed Preserve Med");
|
expect(med.name).toBe("Renamed Preserve Med");
|
||||||
expect(med.stockAdjustment).toBe(-5);
|
expect(med.stockAdjustment).toBe(-5);
|
||||||
});
|
});
|
||||||
@@ -2114,7 +2223,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
const med = data.find((m: any) => m.medicationId === medId);
|
const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
|
||||||
expect(med).toBeDefined();
|
expect(med).toBeDefined();
|
||||||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||||||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||||||
@@ -2201,6 +2310,87 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.settings).toBeDefined();
|
expect(data.settings).toBeDefined();
|
||||||
expect(data.settings.emailEnabled).toBe(true);
|
expect(data.settings.emailEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include sensitive settings when requested", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: "",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "https://example.com/topic",
|
||||||
|
emailStockReminders: false,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
emailPrescriptionReminders: false,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export?includeSensitive=true",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.settings.shoutrrrEnabled).toBe(true);
|
||||||
|
expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should gracefully export malformed date-like DB values", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Date Edge Med",
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id as number;
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-1735344000000`, "not-a-date"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 1, 0, 0, "still-not-a-date"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
args: [userId, "date-edge-token", "Daniel", 30, "broken-date"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/export" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.doseHistory).toHaveLength(1);
|
||||||
|
expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false);
|
||||||
|
expect(data.refillHistory).toHaveLength(1);
|
||||||
|
expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false);
|
||||||
|
expect(data.shareLinks).toHaveLength(1);
|
||||||
|
expect(data.shareLinks[0].expiresAt).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Real /import routes", () => {
|
describe("Real /import routes", () => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
describe("plugins/env runtime validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = {
|
||||||
|
...ORIGINAL_ENV,
|
||||||
|
DOTENV_PATH: "/tmp/medassist-nonexistent.env",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = ORIGINAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads with defaults when auth and oidc are disabled", async () => {
|
||||||
|
delete process.env.AUTH_ENABLED;
|
||||||
|
delete process.env.OIDC_ENABLED;
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.REFRESH_SECRET;
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
|
||||||
|
const mod = await import("../plugins/env.js");
|
||||||
|
expect(mod.env.AUTH_ENABLED).toBe(false);
|
||||||
|
expect(mod.env.OIDC_ENABLED).toBe(false);
|
||||||
|
expect(mod.env.PORT).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when auth is enabled but secrets are missing", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "true";
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.REFRESH_SECRET;
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when oidc is enabled but required settings are missing", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "false";
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
delete process.env.OIDC_ISSUER_URL;
|
||||||
|
delete process.env.OIDC_CLIENT_ID;
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
delete process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads when auth and oidc settings are complete", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "true";
|
||||||
|
process.env.JWT_SECRET = "jwt-secret-for-runtime-test";
|
||||||
|
process.env.REFRESH_SECRET = "refresh-secret-runtime-test";
|
||||||
|
process.env.COOKIE_SECRET = "cookie-secret-runtime-test";
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
process.env.OIDC_ISSUER_URL = "https://auth.example.com";
|
||||||
|
process.env.OIDC_CLIENT_ID = "medassist";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "super-secret-client";
|
||||||
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback";
|
||||||
|
|
||||||
|
const mod = await import("../plugins/env.js");
|
||||||
|
expect(mod.env.AUTH_ENABLED).toBe(true);
|
||||||
|
expect(mod.env.OIDC_ENABLED).toBe(true);
|
||||||
|
expect(mod.env.OIDC_CLIENT_ID).toBe("medassist");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
// Mock process.exit to prevent tests from exiting
|
// Mock process.exit to prevent tests from exiting
|
||||||
const mockExit = vi.fn();
|
const mockExit = vi.fn();
|
||||||
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
|
||||||
|
|
||||||
// Re-create the schema from env.ts for testing
|
// Re-create the schema from env.ts for testing
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
const userId = 1; // Test user ID
|
const userId = 1; // Test user ID
|
||||||
|
|
||||||
// Helper to parse blisters from DB
|
// Helper to parse blisters from DB
|
||||||
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
function parseBlisters(
|
||||||
const usage = JSON.parse(row.usage_json || "[]") as number[];
|
row: Record<string, unknown>
|
||||||
const every = JSON.parse(row.every_json || "[]") as number[];
|
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||||
const start = JSON.parse(row.start_json || "[]") as string[];
|
const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
|
||||||
|
const every = JSON.parse((row.every_json as string) || "[]") as number[];
|
||||||
|
const start = JSON.parse((row.start_json as string) || "[]") as string[];
|
||||||
const len = Math.min(usage.length, every.length, start.length);
|
const len = Math.min(usage.length, every.length, start.length);
|
||||||
return Array.from({ length: len }, (_, i) => ({
|
return Array.from({ length: len }, (_, i) => ({
|
||||||
usage: usage[i],
|
usage: usage[i],
|
||||||
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
args: [userId],
|
args: [userId],
|
||||||
});
|
});
|
||||||
|
|
||||||
let settings;
|
let settings: Record<string, unknown> | undefined;
|
||||||
if (settingsResult.rows.length > 0) {
|
if (settingsResult.rows.length > 0) {
|
||||||
const s = settingsResult.rows[0];
|
const s = settingsResult.rows[0];
|
||||||
settings = {
|
settings = {
|
||||||
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /import
|
// POST /import
|
||||||
app.post<{ Body: any }>("/import", async (request, reply) => {
|
app.post("/import", async (request, reply) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||||
const importData = request.body as any;
|
const importData = request.body as any;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
// Import medications
|
// Import medications
|
||||||
const exportIdToNewId = new Map<string, number>();
|
const exportIdToNewId = new Map<string, number>();
|
||||||
for (const med of importData.medications || []) {
|
for (const med of importData.medications || []) {
|
||||||
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
|
const usageJson = JSON.stringify(
|
||||||
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
|
||||||
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
|
);
|
||||||
|
const everyJson = JSON.stringify(
|
||||||
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
|
||||||
|
);
|
||||||
|
const startJson = JSON.stringify(
|
||||||
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
|
||||||
|
);
|
||||||
const takenByJson = JSON.stringify(med.takenBy || []);
|
const takenByJson = JSON.stringify(med.takenBy || []);
|
||||||
|
|
||||||
const result = await client.execute({
|
const result = await client.execute({
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
@@ -162,6 +165,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -1333,8 +1337,8 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const meds = medsRes.json();
|
const meds = medsRes.json();
|
||||||
const med1 = meds.find((m: any) => m.id === med1Id);
|
const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
|
||||||
const med2 = meds.find((m: any) => m.id === med2Id);
|
const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
|
||||||
|
|
||||||
expect(med1.dismissedUntil).toBe("2025-01-15");
|
expect(med1.dismissedUntil).toBe("2025-01-15");
|
||||||
expect(med2.dismissedUntil).toBe("2025-01-15");
|
expect(med2.dismissedUntil).toBe("2025-01-15");
|
||||||
@@ -1376,7 +1380,7 @@ describe("Integration Tests", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.dismissedUntil).toBeNull();
|
expect(med.dismissedUntil).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1446,7 +1450,7 @@ describe("Integration Tests", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.dismissedUntil).toBeNull();
|
expect(med.dismissedUntil).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type OidcMocks = {
|
||||||
|
discovery: ReturnType<typeof vi.fn>;
|
||||||
|
buildAuthorizationUrl: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
OIDC_ENABLED: true,
|
||||||
|
OIDC_ISSUER_URL: "https://issuer.example.com",
|
||||||
|
OIDC_CLIENT_ID: "medassist-client",
|
||||||
|
OIDC_CLIENT_SECRET: "medassist-client-secret",
|
||||||
|
OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback",
|
||||||
|
OIDC_SCOPES: "openid profile email",
|
||||||
|
OIDC_AUTO_CREATE_USERS: true,
|
||||||
|
OIDC_USERNAME_CLAIM: "preferred_username",
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
CORS_ORIGINS: "http://localhost:5173",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
...envOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.doMock("../plugins/env.js", () => ({ env }));
|
||||||
|
|
||||||
|
vi.doMock("../db/client.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })),
|
||||||
|
})),
|
||||||
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" });
|
||||||
|
const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => {
|
||||||
|
const state = typeof params?.state === "string" ? params.state : "state";
|
||||||
|
return new URL(`https://issuer.example.com/authorize?state=${state}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("openid-client", () => ({
|
||||||
|
discovery,
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
authorizationCodeGrant: vi.fn(),
|
||||||
|
fetchUserInfo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
|
app.decorate("config", {
|
||||||
|
accessSecret: "test-jwt-secret-12345",
|
||||||
|
refreshSecret: "test-refresh-secret-12345",
|
||||||
|
accessTtl: 15 * 60,
|
||||||
|
refreshTtl: 7 * 24 * 60 * 60,
|
||||||
|
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||||
|
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" },
|
||||||
|
});
|
||||||
|
await app.register(oidcRoutes);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
mocks: { discovery, buildAuthorizationUrl } as OidcMocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OIDC routes", () => {
|
||||||
|
it("returns 400 on login and callback when oidc is disabled", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: false });
|
||||||
|
try {
|
||||||
|
const login = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||||
|
const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||||
|
|
||||||
|
expect(login.statusCode).toBe(400);
|
||||||
|
expect(callback.statusCode).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => {
|
||||||
|
const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toContain("https://issuer.example.com/authorize");
|
||||||
|
expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true);
|
||||||
|
expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true);
|
||||||
|
expect(mocks.discovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with provider error when callback contains error params", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when callback is missing required params", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when callback state validation fails", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth/oidc/callback?code=abc123&state=state123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
|
|||||||
|
|
||||||
// Mock sendShoutrrrNotification from settings
|
// Mock sendShoutrrrNotification from settings
|
||||||
vi.mock("../routes/settings.js", async (importOriginal) => {
|
vi.mock("../routes/settings.js", async (importOriginal) => {
|
||||||
const original = (await importOriginal()) as any;
|
const original = (await importOriginal()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
sendShoutrrrNotification: mockSendShoutrrr,
|
sendShoutrrrNotification: mockSendShoutrrr,
|
||||||
@@ -149,6 +149,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
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, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
const env = {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: env,
|
||||||
|
nodemailerSendMail: vi.fn(),
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
default: {
|
||||||
|
createTransport: () => ({
|
||||||
|
sendMail: nodemailerSendMail,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
const { reportRoutes } = await import("../routes/report.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 seedMedication(name = "Aspirin") {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, generic_name, taken_by_json, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
stock_adjustment, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
"Acetylsalicylic acid",
|
||||||
|
JSON.stringify(["Daniel"]),
|
||||||
|
"blister",
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify(["2026-01-01T08:00:00.000Z"]),
|
||||||
|
JSON.stringify([
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", takenBy: "Daniel", intakeRemindersEnabled: true },
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows[0].id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Real route coverage: settings/export/report", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(settingsRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await clearTables();
|
||||||
|
await seedAnonymousUser();
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
delete process.env.SMTP_USER;
|
||||||
|
delete process.env.SMTP_TOKEN;
|
||||||
|
delete process.env.SMTP_PASS;
|
||||||
|
delete process.env.SMTP_FROM;
|
||||||
|
delete process.env.SMTP_PORT;
|
||||||
|
delete process.env.SMTP_SECURE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /settings creates defaults for anonymous user", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.language).toBe("en");
|
||||||
|
expect(body.shareStockStatus).toBe(true);
|
||||||
|
expect(body.upcomingTodayOnly).toBe(false);
|
||||||
|
expect(body.shareScheduleTodayOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: "",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: true,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const stored = await testClient.execute({
|
||||||
|
sql: "SELECT repeat_daily_reminders FROM user_settings WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(stored.rows[0].repeat_daily_reminders).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /settings/language validates supported language", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "fr" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("Invalid language");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("SMTP not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-email sends email when SMTP is configured", async () => {
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_TOKEN = "secret";
|
||||||
|
nodemailerSendMail.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-shoutrrr",
|
||||||
|
payload: { url: "" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||||
|
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("not allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/mytopic",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.stringMatching(/^Basic /),
|
||||||
|
}),
|
||||||
|
method: "POST",
|
||||||
|
redirect: "error",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification uses JSON payload for webhook URLs", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
const result = await sendShoutrrrNotification("https://hooks.slack.com/services/a/b/c", "Title", "Body");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(call[1].headers["Content-Type"]).toBe("application/json");
|
||||||
|
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
||||||
|
await seedMedication("Owned Med");
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [9999] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data aggregates doses and refills", async () => {
|
||||||
|
const medId = await seedMedication("Report Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, 0],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000600000-Daniel`, 1700000600, 1],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 1, 2, 1, 1700001200],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
|
expect(body[medId].dosesDismissed).toBe(1);
|
||||||
|
expect(body[medId].refills).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||||
|
const medId = await seedMedication("Export Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, "Daniel"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 1, 3, 0, 1700000000],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO user_settings (user_id, email_enabled, notification_email, share_stock_status, language) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
args: [1, 1, "x@example.com", 1, "de"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, "abc123", "Daniel", 30],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export?includeSensitive=true&includeImages=false",
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.medications).toHaveLength(1);
|
||||||
|
expect(body.doseHistory).toHaveLength(1);
|
||||||
|
expect(body.refillHistory).toHaveLength(1);
|
||||||
|
expect(body.settings.language).toBe("de");
|
||||||
|
expect(body.shareLinks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /import validates payload and imports minimal valid structure", async () => {
|
||||||
|
const invalid = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: { foo: "bar" },
|
||||||
|
});
|
||||||
|
expect(invalid.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const validImport = {
|
||||||
|
version: "1.1",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
includeSensitiveData: false,
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Med",
|
||||||
|
genericName: null,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
inventory: {
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
totalPills: null,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packageType: "blister",
|
||||||
|
},
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
schedules: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", remind: false, takenBy: "Daniel" }],
|
||||||
|
medicationStartDate: "",
|
||||||
|
expiryDate: null,
|
||||||
|
notes: null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
isObsolete: false,
|
||||||
|
obsoleteAt: null,
|
||||||
|
prescriptionEnabled: false,
|
||||||
|
prescriptionAuthorizedRefills: null,
|
||||||
|
prescriptionRemainingRefills: null,
|
||||||
|
prescriptionLowRefillThreshold: 1,
|
||||||
|
prescriptionExpiryDate: null,
|
||||||
|
dismissedUntil: null,
|
||||||
|
image: null,
|
||||||
|
lastStockCorrectionAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doseHistory: [],
|
||||||
|
refillHistory: [],
|
||||||
|
settings: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: null,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
},
|
||||||
|
shareLinks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const valid = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: validImport,
|
||||||
|
});
|
||||||
|
expect(valid.statusCode).toBe(200);
|
||||||
|
expect(valid.json().imported.medications).toBe(1);
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(rows.rows[0].name).toBe("Imported Med");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import Fastify from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
// Import from utils to avoid index.ts import side effects (server start)
|
// Import from utils to avoid index.ts import side effects (server start)
|
||||||
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
|
|||||||
refreshCookieOptions,
|
refreshCookieOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((app as any).config.accessTtl).toBe(15);
|
const appWithConfig = app as unknown as {
|
||||||
expect((app as any).config.refreshTtl).toBe(7);
|
config: {
|
||||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
accessTtl: number;
|
||||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
refreshTtl: number;
|
||||||
|
cookieOptions: { httpOnly: boolean };
|
||||||
|
refreshCookieOptions: { maxAge: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(appWithConfig.config.accessTtl).toBe(15);
|
||||||
|
expect(appWithConfig.config.refreshTtl).toBe(7);
|
||||||
|
expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true);
|
||||||
|
expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
|
|||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
|
|
||||||
// Mock route plugins
|
// Mock route plugins
|
||||||
const healthRoutes = async (app: any) => {
|
const healthRoutes = async (app: FastifyInstance) => {
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const authRoutes = async (app: any) => {
|
const authRoutes = async (app: FastifyInstance) => {
|
||||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const medicationRoutes = async (app: any) => {
|
const medicationRoutes = async (app: FastifyInstance) => {
|
||||||
app.get("/medications", async () => []);
|
app.get("/medications", async () => []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
|
|||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data).toHaveLength(2);
|
expect(data).toHaveLength(2);
|
||||||
|
|
||||||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
|
||||||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
|
||||||
|
|
||||||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||||||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||||||
|
|||||||
@@ -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 {
|
interface FastifyRequest {
|
||||||
user?: AuthUser | null;
|
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 };
|
||||||
|
}
|
||||||
@@ -191,7 +191,7 @@ export function parseIntakesJson(
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(intakesJson);
|
const parsed = JSON.parse(intakesJson);
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
return parsed.map((intake: any) => ({
|
return parsed.map((intake: Record<string, unknown>) => ({
|
||||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||||
every: typeof intake.every === "number" ? intake.every : 1,
|
every: typeof intake.every === "number" ? intake.every : 1,
|
||||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||||
@@ -312,7 +312,7 @@ export type UpcomingIntake = {
|
|||||||
export function getTodaysIntakes(
|
export function getTodaysIntakes(
|
||||||
medName: string,
|
medName: string,
|
||||||
intakes: Intake[],
|
intakes: Intake[],
|
||||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
@@ -388,7 +388,7 @@ export function getUpcomingIntakes(
|
|||||||
medName: string,
|
medName: string,
|
||||||
intakes: Intake[],
|
intakes: Intake[],
|
||||||
minutesBefore: number,
|
minutesBefore: number,
|
||||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
@@ -483,6 +483,7 @@ export function getUpcomingIntakes(
|
|||||||
export type ReminderState = {
|
export type ReminderState = {
|
||||||
lastAutoEmailSent: string | null;
|
lastAutoEmailSent: string | null;
|
||||||
lastAutoEmailDate: string | null;
|
lastAutoEmailDate: string | null;
|
||||||
|
lastStockSchedulerCheckDate: string | null;
|
||||||
notifiedMedications: string[];
|
notifiedMedications: string[];
|
||||||
nextScheduledCheck: string | null;
|
nextScheduledCheck: string | null;
|
||||||
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
lastNotificationType: "stock" | "intake" | "prescription" | null;
|
||||||
@@ -505,6 +506,7 @@ export function createDefaultReminderState(): ReminderState {
|
|||||||
return {
|
return {
|
||||||
lastAutoEmailSent: null,
|
lastAutoEmailSent: null,
|
||||||
lastAutoEmailDate: null,
|
lastAutoEmailDate: null,
|
||||||
|
lastStockSchedulerCheckDate: null,
|
||||||
notifiedMedications: [],
|
notifiedMedications: [],
|
||||||
nextScheduledCheck: null,
|
nextScheduledCheck: null,
|
||||||
lastNotificationType: null,
|
lastNotificationType: null,
|
||||||
@@ -524,6 +526,7 @@ export function parseReminderState(json: string): ReminderState {
|
|||||||
return {
|
return {
|
||||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||||
|
lastStockSchedulerCheckDate: saved.lastStockSchedulerCheckDate ?? null,
|
||||||
notifiedMedications: saved.notifiedMedications ?? [],
|
notifiedMedications: saved.notifiedMedications ?? [],
|
||||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||||
lastNotificationType: saved.lastNotificationType ?? null,
|
lastNotificationType: saved.lastNotificationType ?? null,
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off"
|
"noForEach": "off",
|
||||||
|
"noImportantStyles": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "warn",
|
"noExplicitAny": "warn",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- frontend_node_modules:/app/node_modules
|
- frontend_node_modules:/app/node_modules
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://backend-dev:3000
|
- BACKEND_URL=http://backend-dev:3000
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
await expect(ibuprofenRow).toBeVisible();
|
await expect(ibuprofenRow).toBeVisible();
|
||||||
const rowText = await ibuprofenRow.textContent();
|
const rowText = await ibuprofenRow.textContent();
|
||||||
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
||||||
expect(rowText).toContain("59");
|
expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show today block in timeline", async ({ page }) => {
|
test("should show today block in timeline", async ({ page }) => {
|
||||||
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
||||||
@@ -154,20 +153,23 @@ test.describe("Dashboard with medications", () => {
|
|||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Normalize state first: if a dose is already taken, undo it so we can
|
||||||
|
// always execute the same take -> undo flow deterministically.
|
||||||
|
const existingUndo = todayBlock.locator("button.dose-btn.undo").first();
|
||||||
|
if (await existingUndo.isVisible().catch(() => false)) {
|
||||||
|
await existingUndo.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
|
||||||
// Mark a dose as taken first
|
// Mark a dose as taken first
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
await expect(takeBtn).toBeVisible({ timeout: 10000 });
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Wait for undo button to appear (confirms the take succeeded)
|
// Wait for undo button to appear (confirms the take succeeded)
|
||||||
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
||||||
try {
|
|
||||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
|
||||||
// Take might have been rate-limited — skip this test gracefully
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await undoBtn.click();
|
await undoBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||||
* since it tests the unauthenticated flow.
|
* since it tests the unauthenticated flow.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{}>({
|
export const test = base.extend<object>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
await setupAuthMeMock(page);
|
await setupAuthMeMock(page);
|
||||||
await use(page);
|
await use(page);
|
||||||
|
|||||||
@@ -38,57 +38,57 @@ async function fillAndSaveMedication(
|
|||||||
intakes?: { usage: string; every: string }[];
|
intakes?: { usage: string; every: string }[];
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
|
||||||
|
if (await openCreateBtn.isVisible().catch(() => false)) {
|
||||||
|
await openCreateBtn.click();
|
||||||
|
}
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
|
||||||
if (opts.genericName) {
|
if (opts.genericName) {
|
||||||
await page.getByLabel(/Generic Name/i).fill(opts.genericName);
|
await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageTypeSelect = form.locator("select.package-type-select");
|
||||||
if (opts.packageType === "bottle") {
|
if (opts.packageType === "bottle") {
|
||||||
await page.locator("select.package-type-select").selectOption("bottle");
|
await packageTypeSelect.selectOption("bottle");
|
||||||
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
|
if (opts.totalCapacity)
|
||||||
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||||
|
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||||
} else {
|
} else {
|
||||||
await page.locator("select.package-type-select").selectOption("blister");
|
await packageTypeSelect.selectOption("blister");
|
||||||
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
|
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
|
||||||
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
|
if (opts.blistersPerPack)
|
||||||
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
|
await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
|
||||||
|
if (opts.pillsPerBlister)
|
||||||
|
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
|
||||||
|
if (opts.loosePills) {
|
||||||
|
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
|
||||||
|
if (await looseField.isVisible().catch(() => false)) {
|
||||||
|
await looseField.fill(opts.loosePills);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
|
if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
|
||||||
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
|
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
|
||||||
|
|
||||||
// Fill intake schedules
|
// Fill intake schedules
|
||||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
for (let i = 0; i < intakes.length; i++) {
|
for (let i = 0; i < intakes.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = page.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
|
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
||||||
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click Save — handle potential rate-limiting by retrying
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.locator("form.form-grid button[type='submit']").click();
|
await form.locator("button[type='submit']").click();
|
||||||
|
|
||||||
// Wait for the form to reset: commercial name becomes empty after successful save
|
|
||||||
try {
|
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
|
|
||||||
break; // Save succeeded
|
|
||||||
} catch {
|
|
||||||
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
|
|
||||||
// Save might have been rate-limited — wait and retry
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
// Re-fill the name in case form was partially reset
|
|
||||||
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
|
|
||||||
if (!currentValue) {
|
|
||||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
||||||
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
|
|||||||
* Helper: save after editing (PUT) and wait for success.
|
* Helper: save after editing (PUT) and wait for success.
|
||||||
*/
|
*/
|
||||||
async function saveEdit(page: Page, medName: string): Promise<void> {
|
async function saveEdit(page: Page, medName: string): Promise<void> {
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.locator("form.form-grid button[type='submit']").click();
|
const submitBtn = form.locator("button[type='submit']");
|
||||||
|
if (
|
||||||
|
(await submitBtn.count()) > 0 &&
|
||||||
|
(await submitBtn
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
await submitBtn.first().click();
|
||||||
|
} else {
|
||||||
|
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||||
|
if (await closeBtn.isVisible().catch(() => false)) {
|
||||||
|
await closeBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Wait for the list to update with the new name — retry with reload if rate-limited
|
// Wait for the list to update with the new name — retry with reload if rate-limited
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
||||||
try {
|
try {
|
||||||
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
|
|||||||
|
|
||||||
test("should not save with empty commercial name", async ({ page }) => {
|
test("should not save with empty commercial name", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Leave name empty — save button should be disabled
|
// Saving without name should not create a medication row.
|
||||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||||
await expect(saveBtn).toBeDisabled();
|
await expect(saveBtn).toBeVisible();
|
||||||
|
await saveBtn.click();
|
||||||
|
await expect(page.locator(".med-row")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reset form after saving a medication", async ({ page }) => {
|
test("should reset form after saving a medication", async ({ page }) => {
|
||||||
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
|
|||||||
pillsPerBlister: "10",
|
pillsPerBlister: "10",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form should reset — title should say "New medication"
|
// Opening a fresh form after save should start with an empty commercial name.
|
||||||
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
|
await page
|
||||||
// Commercial name should be empty
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => {
|
|||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
|
|
||||||
// Form title should say "Edit medication"
|
// Form title should say "Edit entry" (or legacy "Edit medication").
|
||||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
|
await expect(
|
||||||
|
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// The name field should have the current value
|
// The name field should have the current value
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit");
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("After Edit");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
|
||||||
|
|
||||||
// Save the edit
|
// Save the edit
|
||||||
await saveEdit(page, "After Edit");
|
await saveEdit(page, "After Edit");
|
||||||
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
|
|||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Modified Name");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name");
|
||||||
|
|
||||||
// Click Cancel
|
// Click Cancel
|
||||||
await page.locator("form.form-grid button.ghost").click();
|
await page
|
||||||
|
.getByRole("button", { name: /Close|Cancel/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Original name should still be in the list
|
// Original name should still be in the list
|
||||||
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show refill section in edit mode", async ({ page }) => {
|
|
||||||
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
|
|
||||||
await navigateTo(page, "/medications");
|
|
||||||
|
|
||||||
// Click Edit
|
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
|
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
|
||||||
await medRow.locator("button.info").click();
|
|
||||||
|
|
||||||
// Refill section should be visible
|
|
||||||
const refillSection = page.locator(".refill-section");
|
|
||||||
await expect(refillSection).toBeVisible();
|
|
||||||
await expect(refillSection.locator("button.success")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Delete medication", () => {
|
test.describe("Delete medication", () => {
|
||||||
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
|
|||||||
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Accept the native confirm() dialog
|
|
||||||
page.on("dialog", (dialog) => dialog.accept());
|
|
||||||
await medRow.locator("button.danger").click();
|
await medRow.locator("button.danger").click();
|
||||||
|
await page
|
||||||
|
.locator(".confirm-modal-overlay, .modal-overlay")
|
||||||
|
.getByRole("button", { name: /Delete/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Medication should be removed
|
// Medication should be removed
|
||||||
await expect(medRow).not.toBeVisible({ timeout: 5000 });
|
await expect(medRow).toHaveCount(0, { timeout: 10000 });
|
||||||
|
|
||||||
// Already deleted via UI — clear tracked list
|
// Already deleted via UI — clear tracked list
|
||||||
createdMeds.length = 0;
|
createdMeds.length = 0;
|
||||||
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
|
|||||||
test.describe("Intake schedule management", () => {
|
test.describe("Intake schedule management", () => {
|
||||||
test("should add and remove intake schedule rows", async ({ page }) => {
|
test("should add and remove intake schedule rows", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
expect(await page.locator(".blister-row").count()).toBe(1);
|
expect(await form.locator(".blister-row").count()).toBe(1);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(3);
|
expect(await form.locator(".blister-row").count()).toBe(3);
|
||||||
|
|
||||||
const removeBtn = page
|
const removeBtn = page
|
||||||
.locator(".blister-row")
|
.locator("form.form-grid:visible .blister-row")
|
||||||
.last()
|
.last()
|
||||||
.getByRole("button", { name: /Remove/i });
|
.getByRole("button", { name: /Remove/i });
|
||||||
await removeBtn.click();
|
await removeBtn.click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper: save edit and verify success */
|
/** Helper: save edit and verify success */
|
||||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
// Wait for any pending network before clicking save
|
// Wait for any pending network before clicking save
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Click save
|
const submitBtn = form.locator("button[type='submit']");
|
||||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
if (
|
||||||
await saveBtn.click();
|
(await submitBtn.count()) > 0 &&
|
||||||
|
(await submitBtn
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
await submitBtn.first().click();
|
||||||
|
} else {
|
||||||
|
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||||
|
if (await closeBtn.isVisible().catch(() => false)) {
|
||||||
|
await closeBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for save request + re-fetch to complete
|
// Wait for save request + re-fetch to complete
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
|
|||||||
await clickEditMed(page, "Edit GenName Med");
|
await clickEditMed(page, "Edit GenName Med");
|
||||||
|
|
||||||
// Generic name should be empty initially
|
// Generic name should be empty initially
|
||||||
const genericField = page.getByLabel(/Generic Name/i);
|
const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||||
await expect(genericField).toHaveValue("");
|
await expect(genericField).toHaveValue("");
|
||||||
|
|
||||||
// Add a generic name
|
// Add a generic name
|
||||||
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Click edit again and verify the generic name was saved
|
// Click edit again and verify the generic name was saved
|
||||||
await clickEditMed(page, "Edit GenName Med");
|
await clickEditMed(page, "Edit GenName Med");
|
||||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid");
|
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add notes to an existing medication", async ({ page }) => {
|
test("should add notes to an existing medication", async ({ page }) => {
|
||||||
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Edit Notes Med");
|
await clickEditMed(page, "Edit Notes Med");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Notes should be empty initially
|
// Notes should be empty initially
|
||||||
const notesField = page.getByLabel(/Notes/i);
|
const notesField = page.getByLabel(/(Notes|form\.notes)/i);
|
||||||
await expect(notesField).toHaveValue("");
|
await expect(notesField).toHaveValue("");
|
||||||
|
|
||||||
// Add notes text
|
// Add notes text
|
||||||
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Verify notes were saved by clicking edit again
|
// Verify notes were saved by clicking edit again
|
||||||
await clickEditMed(page, "Edit Notes Med");
|
await clickEditMed(page, "Edit Notes Med");
|
||||||
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast");
|
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add taken-by person to a medication", async ({ page }) => {
|
test("should add taken-by person to a medication", async ({ page }) => {
|
||||||
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Expiry Date Med");
|
await clickEditMed(page, "Expiry Date Med");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Set expiry date to 6 months from now
|
// Set expiry date to 6 months from now
|
||||||
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
const expiryField = page.getByLabel(/Expiry Date/i);
|
const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i);
|
||||||
await expiryField.fill(expiryDate);
|
await expiryField.fill(expiryDate);
|
||||||
await expect(expiryField).toHaveValue(expiryDate);
|
await expect(expiryField).toHaveValue(expiryDate);
|
||||||
|
|
||||||
// Also touch the name field to ensure form is dirty
|
// Also touch the name field to ensure form is dirty
|
||||||
const nameField = page.getByLabel(/Commercial Name/i);
|
// Expiry change itself is enough to persist in the current edit flow.
|
||||||
const currentName = await nameField.inputValue();
|
|
||||||
await nameField.fill(currentName);
|
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Expiry Date Med");
|
await saveEditAndVerify(page, "Expiry Date Med");
|
||||||
|
|
||||||
// Verify expiry date was saved
|
// Verify expiry date was saved
|
||||||
await clickEditMed(page, "Expiry Date Med");
|
await clickEditMed(page, "Expiry Date Med");
|
||||||
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate);
|
await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
|
||||||
});
|
|
||||||
|
|
||||||
test("should use refill feature to add stock in edit mode", async ({ page }) => {
|
|
||||||
createdMeds.push(
|
|
||||||
await createMedicationViaAPI({
|
|
||||||
name: "Refill Test Med",
|
|
||||||
packCount: 1,
|
|
||||||
blistersPerPack: 2,
|
|
||||||
pillsPerBlister: 10,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await navigateTo(page, "/medications");
|
|
||||||
|
|
||||||
await clickEditMed(page, "Refill Test Med");
|
|
||||||
|
|
||||||
// Refill section should be visible in edit mode
|
|
||||||
const refillSection = page.locator(".refill-section");
|
|
||||||
await expect(refillSection).toBeVisible();
|
|
||||||
|
|
||||||
// Set refill values: 2 packs + 5 loose pills
|
|
||||||
await refillSection.getByLabel(/Packs/i).fill("2");
|
|
||||||
await refillSection.getByLabel(/Loose pills/i).fill("5");
|
|
||||||
|
|
||||||
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
|
|
||||||
const preview = refillSection.locator(".refill-preview");
|
|
||||||
await expect(preview).toBeVisible();
|
|
||||||
expect(await preview.textContent()).toContain("45");
|
|
||||||
|
|
||||||
// Click the refill button
|
|
||||||
await refillSection.locator("button.success").click();
|
|
||||||
|
|
||||||
// Wait for the refill to be processed
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit intake schedule usage and interval", async ({ page }) => {
|
test("should edit intake schedule usage and interval", async ({ page }) => {
|
||||||
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
|
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
||||||
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
|
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
|
|
||||||
await usageField.fill("2");
|
await usageField.fill("2");
|
||||||
await everyField.fill("7");
|
await everyField.fill("7");
|
||||||
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
|
|||||||
// Verify the changes persisted
|
// Verify the changes persisted
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
const savedRow = page.locator(".blister-row").first();
|
const savedRow = page.locator(".blister-row").first();
|
||||||
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
|
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||||
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
|
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add a second intake schedule row", async ({ page }) => {
|
test("should add a second intake schedule row", async ({ page }) => {
|
||||||
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Add Intake Med");
|
await clickEditMed(page, "Add Intake Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Should have 1 intake row initially
|
// Should have 1 intake row initially
|
||||||
await expect(page.locator(".blister-row")).toHaveCount(1);
|
await expect(page.locator(".blister-row")).toHaveCount(1);
|
||||||
|
|
||||||
// Add a second intake
|
// Add a second intake
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
await expect(page.locator(".blister-row")).toHaveCount(2);
|
await expect(page.locator(".blister-row")).toHaveCount(2);
|
||||||
|
|
||||||
// Fill the new intake row
|
// Fill the new intake row
|
||||||
const secondRow = page.locator(".blister-row").nth(1);
|
const secondRow = page.locator(".blister-row").nth(1);
|
||||||
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
|
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
||||||
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
|
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Add Intake Med");
|
await saveEditAndVerify(page, "Add Intake Med");
|
||||||
|
|
||||||
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Reminder Toggle Med");
|
await clickEditMed(page, "Reminder Toggle Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Find the remind checkbox in the intake row
|
// Find the remind checkbox in the intake row
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
// Should be blister type initially
|
// Should be blister type initially
|
||||||
const packageSelect = page.locator("select.package-type-select");
|
const packageSelect = form.locator("select.package-type-select");
|
||||||
await expect(packageSelect).toHaveValue("blister");
|
await expect(packageSelect).toHaveValue("blister");
|
||||||
|
|
||||||
// Blister-specific fields should be visible
|
// Blister-specific fields are shown in the Package tab.
|
||||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Switch to bottle
|
// Switch to bottle
|
||||||
await packageSelect.selectOption("bottle");
|
await packageSelect.selectOption("bottle");
|
||||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||||
|
|
||||||
// Fill bottle-specific fields
|
// Fill bottle-specific fields
|
||||||
await page.getByLabel(/Total Capacity/i).fill("120");
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
|
|||||||
await clickEditMed(page, "Multi Edit Med");
|
await clickEditMed(page, "Multi Edit Med");
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med");
|
||||||
|
|
||||||
// Add generic name
|
// Add generic name
|
||||||
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
|
await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
|
||||||
|
|
||||||
// Add notes
|
// Add notes
|
||||||
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water.");
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water.");
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Add a taken-by person
|
// Add a taken-by person
|
||||||
const takenByInput = page.locator(".tag-input-container input");
|
const takenByInput = page.locator(".tag-input-container input");
|
||||||
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Verify all changes persisted
|
// Verify all changes persisted
|
||||||
await clickEditMed(page, "Fully Edited Med");
|
await clickEditMed(page, "Fully Edited Med");
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
|
||||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
|
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
|
||||||
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
|
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
|
||||||
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import { authFile, navigateTo, test } from "./fixtures";
|
|||||||
test.describe("Medications Page", () => {
|
test.describe("Medications Page", () => {
|
||||||
test.use({ storageState: authFile });
|
test.use({ storageState: authFile });
|
||||||
|
|
||||||
|
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
async function openMedicationForm(page: Page) {
|
async function openMedicationForm(page: Page) {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
const newMedicationButton = page.getByRole("button", { name: /New medication/i });
|
const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
if (await newMedicationButton.isVisible().catch(() => false)) {
|
if (await nameField.isVisible().catch(() => false)) return;
|
||||||
await newMedicationButton.click();
|
|
||||||
|
const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i });
|
||||||
|
if (await newEntryButton.isVisible().catch(() => false)) {
|
||||||
|
await newEntryButton.click();
|
||||||
|
await expect(nameField).toBeVisible({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +35,8 @@ test.describe("Medications Page", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
// Should show either medication entries or the new medication form
|
// Should show either medication entries or the new medication form
|
||||||
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i });
|
const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
|
||||||
const formTitle = page.locator("h2").filter({ hasText: /New medication/i });
|
const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
|
||||||
|
|
||||||
const hasList = await listTitle.isVisible().catch(() => false);
|
const hasList = await listTitle.isVisible().catch(() => false);
|
||||||
const hasForm = await formTitle.isVisible().catch(() => false);
|
const hasForm = await formTitle.isVisible().catch(() => false);
|
||||||
@@ -40,85 +46,92 @@ test.describe("Medications Page", () => {
|
|||||||
|
|
||||||
test("should display the medication form with required fields", async ({ page }) => {
|
test("should display the medication form with required fields", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
const commercialName = page.getByLabel(/Commercial Name/i);
|
const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
await expect(commercialName).toBeVisible();
|
await expect(commercialName).toBeVisible();
|
||||||
|
|
||||||
// Package type selector should exist
|
// Package type selector should exist
|
||||||
await expect(page.getByText(/Package Type/i)).toBeVisible();
|
await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible();
|
||||||
|
|
||||||
// Intake schedule section should exist
|
// Tabbed form should expose navigation to Package/Schedule sections
|
||||||
await expect(page.getByText(/Intake schedule/i)).toBeVisible();
|
await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fill in medication details", async ({ page }) => {
|
test("should fill in medication details", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
const nameField = page.getByLabel(/Commercial Name/i);
|
const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
await nameField.fill("Test Aspirin");
|
await nameField.fill("Test Aspirin");
|
||||||
await expect(nameField).toHaveValue("Test Aspirin");
|
await expect(nameField).toHaveValue("Test Aspirin");
|
||||||
|
|
||||||
const genericField = page.getByLabel(/Generic Name/i);
|
const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||||
await genericField.fill("Acetylsalicylic acid");
|
await genericField.fill("Acetylsalicylic acid");
|
||||||
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have stock inventory fields", async ({ page }) => {
|
test("should have stock inventory fields", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Stock fields should be visible
|
// Package tab should expose stock-related fields for at least one package mode.
|
||||||
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
|
const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first();
|
||||||
|
const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first();
|
||||||
|
|
||||||
// Either blister or bottle fields depending on package type
|
const hasPacks = await packsField.isVisible().catch(() => false);
|
||||||
const blistersField = page.getByLabel(/Blisters per pack/i);
|
const hasTotal = await totalField.isVisible().catch(() => false);
|
||||||
const pillsField = page.getByLabel(/Pills per blister/i);
|
|
||||||
const capacityField = page.getByLabel(/Total Capacity/i);
|
|
||||||
|
|
||||||
const hasBlister = await blistersField.isVisible().catch(() => false);
|
expect(hasPacks || hasTotal).toBeTruthy();
|
||||||
const hasBottle = await capacityField.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
expect(hasBlister || hasBottle).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
test("should toggle package type between blister and bottle", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Find the package type radio buttons or selector
|
// Find the package type radio buttons or selector
|
||||||
const blisterOption = page.getByText(/Blister Pack/i);
|
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
||||||
const bottleOption = page.getByText(/Pill Bottle/i);
|
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
||||||
|
|
||||||
if (await blisterOption.isVisible().catch(() => false)) {
|
if (await blisterOption.isVisible().catch(() => false)) {
|
||||||
// Switch to bottle
|
// Switch to bottle
|
||||||
await bottleOption.click();
|
await bottleOption.click();
|
||||||
// Bottle-specific fields should appear
|
// Bottle-specific fields should appear
|
||||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
||||||
|
|
||||||
// Switch back to blister
|
// Switch back to blister
|
||||||
await blisterOption.click();
|
await blisterOption.click();
|
||||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have intake schedule with add button", async ({ page }) => {
|
test("should have intake schedule with add button", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Intake schedule section
|
// Intake schedule section
|
||||||
const scheduleSection = page.getByText(/Intake schedule/i);
|
await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
|
||||||
await expect(scheduleSection).toBeVisible();
|
|
||||||
|
|
||||||
// Should have at least one intake entry
|
// Should have at least one intake entry
|
||||||
await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible();
|
await expect(
|
||||||
|
form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first()
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Should have an add intake button
|
// Should have an add intake button
|
||||||
const addIntake = page.getByRole("button", { name: /Intake/i });
|
const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
|
||||||
await expect(addIntake).toBeVisible();
|
await expect(addIntake).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have save and cancel buttons", async ({ page }) => {
|
test("should have save and cancel buttons", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
// Fill in a name to make the form dirty
|
// Fill in a name to make the form dirty
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Test");
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test");
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
||||||
@@ -127,9 +140,10 @@ test.describe("Medications Page", () => {
|
|||||||
|
|
||||||
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
// Fill in the form to create unsaved changes
|
// Fill in the form to create unsaved changes
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication");
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication");
|
||||||
|
|
||||||
// Try to navigate away
|
// Try to navigate away
|
||||||
await page.locator('button.pill:has-text("Dashboard")').click();
|
await page.locator('button.pill:has-text("Dashboard")').click();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
|
|||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { authFile, navigateTo, test } from "./fixtures";
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule / Timeline E2E Tests
|
* Schedule / Timeline E2E Tests
|
||||||
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
|
|||||||
test.describe("Schedule Timeline", () => {
|
test.describe("Schedule Timeline", () => {
|
||||||
test.use({ storageState: authFile });
|
test.use({ storageState: authFile });
|
||||||
|
|
||||||
|
const seededName = "Schedule Smoke Seed";
|
||||||
|
const startThreeDaysAgo = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 3);
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: seededName,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
test("should have timeline container in DOM", async ({ page }) => {
|
test("should have timeline container in DOM", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show past days toggle when medications exist", async ({ page }) => {
|
test("should show past days toggle when medications exist", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Past days toggle only appears when there are scheduled medications
|
// Past days toggle appears when there are scheduled medications
|
||||||
const pastToggle = page.locator(".past-days-toggle");
|
const pastToggle = page.locator(".past-days-toggle");
|
||||||
const hasPastToggle = await pastToggle.isVisible().catch(() => false);
|
await expect(pastToggle).toBeVisible();
|
||||||
|
|
||||||
// Just verify it doesn't crash — visibility depends on medication data
|
|
||||||
expect(typeof hasPastToggle).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should expand/collapse past days on click", async ({ page }) => {
|
test("should expand/collapse past days on click", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const pastToggle = page.locator(".past-days-toggle");
|
const pastToggle = page.locator(".past-days-toggle");
|
||||||
if (!(await pastToggle.isVisible().catch(() => false))) {
|
await expect(pastToggle).toBeVisible();
|
||||||
// No medications — past days toggle not shown
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
||||||
|
|
||||||
@@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show future days toggle when medications exist", async ({ page }) => {
|
test("should show future days toggle when medications exist", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Future days toggle only appears when there are scheduled medications
|
// Future days toggle appears when there are scheduled medications
|
||||||
const futureToggle = page.locator(".future-days-toggle");
|
const futureToggle = page.locator(".future-days-toggle");
|
||||||
const hasFutureToggle = await futureToggle.isVisible().catch(() => false);
|
await expect(futureToggle).toBeVisible();
|
||||||
expect(typeof hasFutureToggle).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display day blocks in timeline", async ({ page }) => {
|
test("should display day blocks in timeline", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// There should be at least one day block (today)
|
// With medications there should be day blocks; otherwise empty-state is expected.
|
||||||
const dayBlocks = page.locator(".day-block");
|
const dayBlocks = page.locator(".day-block");
|
||||||
expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0);
|
const dayBlockCount = await dayBlocks.count();
|
||||||
|
if (dayBlockCount === 0) {
|
||||||
|
await expect(page.getByText(/No medications/i)).toBeVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(dayBlockCount).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should highlight today block", async ({ page }) => {
|
test("should highlight today block", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// If there are medications, today should be highlighted
|
// With medications, today should be highlighted
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
const hasTodayBlock = await todayBlock.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
// Today block exists only if there are medications with schedules
|
|
||||||
if (hasTodayBlock) {
|
|
||||||
await expect(todayBlock).toBeVisible();
|
await expect(todayBlock).toBeVisible();
|
||||||
// Should have a day divider with date text
|
|
||||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show day summary with progress", async ({ page }) => {
|
test("should show day summary with progress", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
if (await todayBlock.isVisible().catch(() => false)) {
|
await expect(todayBlock).toBeVisible();
|
||||||
const summary = todayBlock.locator(".day-summary");
|
const summary = todayBlock.locator(".day-summary");
|
||||||
await expect(summary).toBeVisible();
|
await expect(summary).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should collapse/expand a day block", async ({ page }) => {
|
test("should collapse/expand a day block", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
if (await todayBlock.isVisible().catch(() => false)) {
|
await expect(todayBlock).toBeVisible();
|
||||||
const dayDivider = todayBlock.locator(".day-divider");
|
const dayDivider = todayBlock.locator(".day-divider");
|
||||||
await dayDivider.click();
|
await dayDivider.click();
|
||||||
|
|
||||||
// Check if it toggled collapsed state
|
|
||||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||||
|
|
||||||
// Click again to restore
|
|
||||||
await dayDivider.click();
|
await dayDivider.click();
|
||||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||||
|
|
||||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
|
|||||||
|
|
||||||
// Overview table has class .table.table-7
|
// Overview table has class .table.table-7
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".table.table-7");
|
||||||
const hasTable = await overviewTable.isVisible().catch(() => false);
|
await expect(overviewTable).toBeVisible();
|
||||||
|
|
||||||
// Table only visible if medications exist
|
|
||||||
if (hasTable) {
|
|
||||||
// Table should have a header row
|
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display share button in schedules section", async ({ page }) => {
|
test("should display share button in schedules section", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
||||||
|
|
||||||
const shareBtn = page.locator("button.share-btn");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
// Share button only visible if there are takenBy users
|
await expect(shareBtn).toBeVisible();
|
||||||
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
// Just verify it's either visible or not (no crash)
|
|
||||||
expect(typeof hasShareBtn).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabledToggle) {
|
test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
|
||||||
// All toggles disabled (no notification channels configured) — skip
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
||||||
const initialState = await checkbox.isChecked();
|
const initialState = await checkbox.isChecked();
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
// Should show the shared schedule page (not the login page)
|
// Should show the shared schedule page (not the login page)
|
||||||
// Wait for either the schedule content or an error
|
// Wait for either the schedule content or an error
|
||||||
const sharedContent = page.locator(".shared-schedule, .share-page");
|
const _sharedContent = page.locator(".shared-schedule, .share-page");
|
||||||
const dayBlock = page.locator(".day-block");
|
const dayBlock = page.locator(".day-block");
|
||||||
const medName = page.getByText(MED_ALICE);
|
const medName = page.getByText(MED_ALICE);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<title>MedAssist-ng</title>
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- 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/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
|
|
||||||
<!-- Theme color -->
|
<!-- Theme color -->
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
case "${LOG_LEVEL:-info}" in
|
# Normalize: lowercase + trim whitespace
|
||||||
|
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
|
||||||
|
case "$level" in
|
||||||
warn|error|fatal|silent)
|
warn|error|fatal|silent)
|
||||||
export NGINX_ACCESS_LOG="off"
|
export NGINX_ACCESS_LOG="off"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
export NGINX_ACCESS_LOG="/dev/stdout"
|
export NGINX_ACCESS_LOG="/dev/stdout"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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)
|
# Allow larger file uploads (for medication images and data import/export)
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
@@ -43,5 +45,9 @@ server {
|
|||||||
# Timeout for uploads
|
# Timeout for uploads
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
|
|
||||||
|
# Prevent buffering upstream responses to temp files (images can be large)
|
||||||
|
# nginx streams directly to client instead of buffering the full response
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+157
-118
@@ -1,33 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.10.3",
|
"version": "1.15.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.10.3",
|
"version": "1.15.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.7",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
@@ -48,23 +50,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
|
||||||
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
|
"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-calc": "^2.1.4",
|
"@csstools/css-calc": "^3.0.0",
|
||||||
"@csstools/css-color-parser": "^3.1.0",
|
"@csstools/css-color-parser": "^4.0.1",
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -72,9 +74,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
"version": "6.7.6",
|
"version": "6.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||||
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
|
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -82,13 +84,13 @@
|
|||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -404,9 +406,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz",
|
||||||
"integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==",
|
"integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -420,20 +422,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.3.15",
|
"@biomejs/cli-darwin-arm64": "2.4.4",
|
||||||
"@biomejs/cli-darwin-x64": "2.3.15",
|
"@biomejs/cli-darwin-x64": "2.4.4",
|
||||||
"@biomejs/cli-linux-arm64": "2.3.15",
|
"@biomejs/cli-linux-arm64": "2.4.4",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.3.15",
|
"@biomejs/cli-linux-arm64-musl": "2.4.4",
|
||||||
"@biomejs/cli-linux-x64": "2.3.15",
|
"@biomejs/cli-linux-x64": "2.4.4",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.3.15",
|
"@biomejs/cli-linux-x64-musl": "2.4.4",
|
||||||
"@biomejs/cli-win32-arm64": "2.3.15",
|
"@biomejs/cli-win32-arm64": "2.4.4",
|
||||||
"@biomejs/cli-win32-x64": "2.3.15"
|
"@biomejs/cli-win32-x64": "2.4.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==",
|
"integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -448,9 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==",
|
"integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -465,9 +467,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==",
|
"integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -482,9 +484,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz",
|
||||||
"integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==",
|
"integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -499,9 +501,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==",
|
"integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -516,9 +518,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz",
|
||||||
"integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==",
|
"integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -533,9 +535,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==",
|
"integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -550,9 +552,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==",
|
"integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -566,10 +568,23 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
|
||||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
"integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -583,13 +598,13 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-calc": {
|
"node_modules/@csstools/css-calc": {
|
||||||
"version": "2.1.4",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -603,17 +618,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-color-parser": {
|
"node_modules/@csstools/css-color-parser": {
|
||||||
"version": "3.1.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
|
||||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
"integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -627,21 +642,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/color-helpers": "^5.1.0",
|
"@csstools/color-helpers": "^6.0.1",
|
||||||
"@csstools/css-calc": "^2.1.4"
|
"@csstools/css-calc": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-parser-algorithms": {
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
"version": "3.0.5",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -655,16 +670,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
"version": "1.0.25",
|
"version": "1.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
|
||||||
"integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
|
"integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -676,15 +691,12 @@
|
|||||||
"url": "https://opencollective.com/csstools"
|
"url": "https://opencollective.com/csstools"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT-0",
|
"license": "MIT-0"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
"version": "3.0.4",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -698,7 +710,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -1724,6 +1736,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -2139,25 +2161,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle": {
|
"node_modules/cssstyle": {
|
||||||
"version": "5.3.7",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
|
||||||
"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
|
"integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^4.1.1",
|
"@asamuzakjp/css-color": "^4.1.2",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
|
"@csstools/css-syntax-patches-for-csstree": "^1.0.26",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2438,9 +2460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.8.7",
|
"version": "25.8.13",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
|
||||||
"integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==",
|
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2540,16 +2562,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "28.0.0",
|
"version": "28.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@acemir/cssom": "^0.9.31",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@exodus/bytes": "^1.11.0",
|
"@exodus/bytes": "^1.11.0",
|
||||||
"cssstyle": "^5.3.7",
|
"cssstyle": "^6.0.1",
|
||||||
"data-urls": "^7.0.0",
|
"data-urls": "^7.0.0",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
@@ -2560,7 +2583,7 @@
|
|||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"undici": "^7.20.0",
|
"undici": "^7.21.0",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
@@ -2627,6 +2650,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -2962,9 +2994,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.0",
|
"version": "7.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -2984,12 +3016,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.0",
|
"version": "7.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.0"
|
"react-router": "7.13.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -3281,6 +3313,13 @@
|
|||||||
"node": ">=20.18.1"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
+15
-9
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.11.1",
|
"version": "1.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,33 +14,39 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm",
|
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
||||||
|
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
|
||||||
|
"test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui",
|
||||||
|
"test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed",
|
||||||
|
"test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug",
|
||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.7",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
|
export default buildPlaywrightConfig(true);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
||||||
|
const env =
|
||||||
|
typeof globalThis === "object" && "process" in globalThis
|
||||||
|
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
||||||
|
: {};
|
||||||
|
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
|
{
|
||||||
|
name: "setup",
|
||||||
|
testMatch: /.*\.setup\.ts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
retries: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium-data",
|
||||||
|
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
dependencies: ["setup"],
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (runAllBrowsers) {
|
||||||
|
projects.push(
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Firefox"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
testMatch: "**/*.spec.ts",
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!env.CI,
|
||||||
|
retries: env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: env.CI
|
||||||
|
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||||
|
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "on",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
navigationTimeout: 30000,
|
||||||
|
actionTimeout: 5000,
|
||||||
|
},
|
||||||
|
projects,
|
||||||
|
outputDir: "test-results/",
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: "cd ../backend && npm run dev",
|
||||||
|
url: "http://localhost:3000/health",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,153 +1,3 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
/**
|
export default buildPlaywrightConfig(false);
|
||||||
* Playwright E2E Testing Configuration
|
|
||||||
*
|
|
||||||
* Run E2E tests with:
|
|
||||||
* npm run test:e2e - Run tests in headless mode
|
|
||||||
* npm run test:e2e:ui - Run tests with Playwright UI
|
|
||||||
* npm run test:e2e:headed - Run tests in headed mode
|
|
||||||
*
|
|
||||||
* Before running tests, ensure both backend and frontend are running:
|
|
||||||
* docker compose -f docker-compose.dev.yml up
|
|
||||||
*
|
|
||||||
* Or run them separately:
|
|
||||||
* cd backend && npm run dev
|
|
||||||
* cd frontend && npm run dev
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Base URL for the frontend dev server
|
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
// Directory containing test files
|
|
||||||
testDir: "./e2e",
|
|
||||||
|
|
||||||
// Test file pattern
|
|
||||||
testMatch: "**/*.spec.ts",
|
|
||||||
|
|
||||||
// Maximum time one test can run
|
|
||||||
timeout: 30 * 1000,
|
|
||||||
|
|
||||||
// Maximum time to wait for expect assertions
|
|
||||||
expect: {
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Run tests in parallel
|
|
||||||
fullyParallel: true,
|
|
||||||
|
|
||||||
// Fail the build on CI if you accidentally left test.only in the source code
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
|
|
||||||
// Retry failed tests (more retries on CI)
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
|
|
||||||
// Opt out of parallel tests on CI
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
|
|
||||||
// Reporter configuration
|
|
||||||
reporter: process.env.CI
|
|
||||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
|
||||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
|
||||||
|
|
||||||
// Shared settings for all projects
|
|
||||||
use: {
|
|
||||||
// Base URL for page.goto() calls
|
|
||||||
baseURL,
|
|
||||||
|
|
||||||
// Collect trace on first retry
|
|
||||||
trace: "on-first-retry",
|
|
||||||
|
|
||||||
// Capture screenshot on failure
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
|
|
||||||
// Record video for every test so runs can be reviewed
|
|
||||||
video: "on",
|
|
||||||
|
|
||||||
// Default viewport size
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
|
|
||||||
// Wait for network idle before considering navigation complete
|
|
||||||
navigationTimeout: 30000,
|
|
||||||
|
|
||||||
// Accept cookies and local storage
|
|
||||||
actionTimeout: 5000,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Configure projects for multiple browsers
|
|
||||||
projects: [
|
|
||||||
// Setup project for authentication state
|
|
||||||
{
|
|
||||||
name: "setup",
|
|
||||||
testMatch: /.*\.setup\.ts/,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Chrome — primary test browser, always runs
|
|
||||||
// Excludes data/crud tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
retries: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Firefox — runs locally and optionally in CI
|
|
||||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "firefox",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Firefox"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Safari — runs locally and optionally in CI
|
|
||||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "webkit",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data tests — only Chromium, run serially to avoid DB conflicts
|
|
||||||
// These tests create/edit/delete medications and must not run concurrently
|
|
||||||
// across browsers since all share the same backend database.
|
|
||||||
{
|
|
||||||
name: "chromium-data",
|
|
||||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
dependencies: ["setup"],
|
|
||||||
fullyParallel: false,
|
|
||||||
retries: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Directory for test output files (screenshots, traces, videos)
|
|
||||||
outputDir: "test-results/",
|
|
||||||
|
|
||||||
// Web server configuration — automatically start dev servers in CI
|
|
||||||
webServer: [
|
|
||||||
{
|
|
||||||
command: "cd ../backend && npm run dev",
|
|
||||||
url: "http://localhost:3000/health",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: "npm run dev",
|
|
||||||
url: "http://localhost:5173",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
|
export default buildPlaywrightConfig(false);
|
||||||
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 |
+185
-90
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AboutModal,
|
AboutModal,
|
||||||
Lightbox,
|
Lightbox,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { AppHeader } from "./components/AppHeader";
|
import { AppHeader } from "./components/AppHeader";
|
||||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||||
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
import { AppProvider, UnsavedChangesProvider, useAppContext } from "./context";
|
||||||
|
import { useScrollLock } from "./hooks/useScrollLock";
|
||||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage } from "./pages";
|
||||||
|
|
||||||
// Vite injects this at build time from package.json
|
// Vite injects this at build time from package.json
|
||||||
@@ -112,14 +113,14 @@ function AppRouter() {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
// Get shared state from AppContext
|
// Get shared state from AppContext
|
||||||
const ctx = useAppContext();
|
const ctx = useAppContext();
|
||||||
const {
|
const {
|
||||||
// Medications
|
// Medications
|
||||||
meds,
|
meds,
|
||||||
loadMeds,
|
loadMeds,
|
||||||
// Settings
|
|
||||||
settings,
|
|
||||||
// Refill
|
// Refill
|
||||||
showRefillModal,
|
showRefillModal,
|
||||||
setShowRefillModal,
|
setShowRefillModal,
|
||||||
@@ -139,7 +140,10 @@ function AppContent() {
|
|||||||
setEditStockFullBlisters,
|
setEditStockFullBlisters,
|
||||||
editStockPartialBlisterPills,
|
editStockPartialBlisterPills,
|
||||||
setEditStockPartialBlisterPills,
|
setEditStockPartialBlisterPills,
|
||||||
|
editStockLoosePills,
|
||||||
|
setEditStockLoosePills,
|
||||||
editStockSaving,
|
editStockSaving,
|
||||||
|
editStockMedication,
|
||||||
openRefillModal,
|
openRefillModal,
|
||||||
closeRefillModal,
|
closeRefillModal,
|
||||||
openEditStockModal,
|
openEditStockModal,
|
||||||
@@ -186,59 +190,24 @@ function AppContent() {
|
|||||||
// Local-only state (not shared across components)
|
// Local-only state (not shared across components)
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [showAbout, setShowAbout] = 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
|
// Get centralized stockThresholds from context
|
||||||
const { stockThresholds } = ctx;
|
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)
|
// Handle browser back button to close modals (in priority order)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -289,18 +258,25 @@ function AppContent() {
|
|||||||
// Close tooltips on scroll/touch (for mobile)
|
// Close tooltips on scroll/touch (for mobile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closeAllTooltips = () => {
|
const closeAllTooltips = () => {
|
||||||
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => {
|
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
||||||
el.classList.remove("tooltip-active");
|
el.classList.remove("tooltip-active");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTooltipClick = (e: Event) => {
|
const handleTooltipClick = (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.classList.contains("info-tooltip")) {
|
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
||||||
|
if (tooltipTrigger) {
|
||||||
// Close other tooltips first
|
// Close other tooltips first
|
||||||
closeAllTooltips();
|
closeAllTooltips();
|
||||||
// Toggle this one
|
// Toggle this one
|
||||||
target.classList.add("tooltip-active");
|
tooltipTrigger.classList.add("tooltip-active");
|
||||||
|
// Position tooltip above the icon on mobile
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
const rect = tooltipTrigger.getBoundingClientRect();
|
||||||
|
// Place tooltip bottom edge just above the icon
|
||||||
|
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
closeAllTooltips();
|
closeAllTooltips();
|
||||||
}
|
}
|
||||||
@@ -320,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(() => {
|
useEffect(() => {
|
||||||
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (isModalOpen) {
|
if (e.key !== "Escape") return;
|
||||||
document.documentElement.classList.add("modal-open");
|
|
||||||
document.body.classList.add("modal-open");
|
if (scheduleLightboxImage) {
|
||||||
} else {
|
closeScheduleLightbox();
|
||||||
document.documentElement.classList.remove("modal-open");
|
return;
|
||||||
document.body.classList.remove("modal-open");
|
}
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
document.documentElement.classList.remove("modal-open");
|
|
||||||
document.body.classList.remove("modal-open");
|
|
||||||
};
|
};
|
||||||
}, [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)
|
// Update selectedMed when meds change (e.g., after refill)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -351,9 +392,11 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [meds, selectedMed, setSelectedMed]);
|
}, [meds, selectedMed, setSelectedMed]);
|
||||||
|
|
||||||
|
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
|
||||||
|
|
||||||
const handleSubmitStockCorrection = async (medId: number) => {
|
const handleSubmitStockCorrection = async (medId: number) => {
|
||||||
if (!selectedMed) return;
|
if (!stockCorrectionMed) return;
|
||||||
await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
|
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
|
||||||
};
|
};
|
||||||
|
|
||||||
// For MedDetailModal: refill without form update (not editing)
|
// For MedDetailModal: refill without form update (not editing)
|
||||||
@@ -361,32 +404,78 @@ function AppContent() {
|
|||||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
useEffect(() => {
|
||||||
const handleOpenEditStockModal = () => {
|
if (!routeTransitionMaskActive) return;
|
||||||
if (selectedMed) {
|
if (location.pathname !== "/medications") return;
|
||||||
openEditStockModal(selectedMed, coverage);
|
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
function openProfile() {
|
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);
|
||||||
|
setSelectedMed(null);
|
||||||
|
navigate(`/medications?editMedId=${medId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditStockFromDetail = () => {
|
||||||
|
if (!selectedMed) return;
|
||||||
|
openEditStockModal(selectedMed, coverage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProfile = useCallback(() => {
|
||||||
setShowProfile(true);
|
setShowProfile(true);
|
||||||
window.history.pushState({ modal: "profile" }, "");
|
window.history.pushState({ modal: "profile" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeProfile() {
|
|
||||||
if (showProfile) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAbout() {
|
const openAbout = useCallback(() => {
|
||||||
setShowAbout(true);
|
setShowAbout(true);
|
||||||
window.history.pushState({ modal: "about" }, "");
|
window.history.pushState({ modal: "about" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeAbout() {
|
|
||||||
if (showAbout) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
@@ -415,18 +504,20 @@ function AppContent() {
|
|||||||
|
|
||||||
{/* Medication Detail Modal */}
|
{/* Medication Detail Modal */}
|
||||||
<MedDetailModal
|
<MedDetailModal
|
||||||
selectedMed={selectedMed}
|
selectedMed={stockCorrectionMed}
|
||||||
coverage={coverage}
|
coverage={coverage}
|
||||||
settings={stockThresholds}
|
settings={stockThresholds}
|
||||||
showImageLightbox={showImageLightbox}
|
showImageLightbox={showImageLightbox}
|
||||||
showRefillModal={showRefillModal}
|
showRefillModal={showRefillModal}
|
||||||
showEditStockModal={showEditStockModal}
|
showEditStockModal={showEditStockModal}
|
||||||
|
editStockOnly={showEditStockModal && !selectedMed}
|
||||||
onClose={closeMedDetail}
|
onClose={closeMedDetail}
|
||||||
onOpenImageLightbox={openImageLightbox}
|
onOpenImageLightbox={openImageLightbox}
|
||||||
onCloseImageLightbox={closeImageLightbox}
|
onCloseImageLightbox={closeImageLightbox}
|
||||||
onOpenRefillModal={openRefillModal}
|
onOpenRefillModal={openRefillModal}
|
||||||
onCloseRefillModal={closeRefillModal}
|
onCloseRefillModal={closeRefillModal}
|
||||||
onOpenEditStockModal={handleOpenEditStockModal}
|
onOpenMedicationEdit={handleOpenMedicationEdit}
|
||||||
|
onOpenEditStockModal={handleOpenEditStockFromDetail}
|
||||||
onCloseEditStockModal={closeEditStockModal}
|
onCloseEditStockModal={closeEditStockModal}
|
||||||
refillPacks={refillPacks}
|
refillPacks={refillPacks}
|
||||||
onRefillPacksChange={setRefillPacks}
|
onRefillPacksChange={setRefillPacks}
|
||||||
@@ -443,6 +534,8 @@ function AppContent() {
|
|||||||
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
||||||
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
||||||
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
||||||
|
editStockLoosePills={editStockLoosePills}
|
||||||
|
onEditStockLoosePillsChange={setEditStockLoosePills}
|
||||||
editStockSaving={editStockSaving}
|
editStockSaving={editStockSaving}
|
||||||
onSubmitStockCorrection={handleSubmitStockCorrection}
|
onSubmitStockCorrection={handleSubmitStockCorrection}
|
||||||
/>
|
/>
|
||||||
@@ -484,6 +577,8 @@ function AppContent() {
|
|||||||
{scheduleLightboxImage && (
|
{scheduleLightboxImage && (
|
||||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
|
||||||
interface UpdateCheckResult {
|
interface UpdateCheckResult {
|
||||||
status: "up-to-date" | "update-available" | "error";
|
status: "up-to-date" | "update-available" | "error";
|
||||||
@@ -17,6 +18,8 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
|||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||||
|
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
// Reset check result when modal opens so stale results are never shown
|
// Reset check result when modal opens so stale results are never shown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -51,14 +54,26 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content about-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<div className="about-header">
|
<div className="about-header">
|
||||||
<div className="about-logo">
|
<div className="about-logo">
|
||||||
<img src="/favicon.svg" alt="MedAssist-ng" />
|
<img src="/app-logo.png" alt="MedAssist-ng" />
|
||||||
</div>
|
</div>
|
||||||
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
||||||
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useUnsavedChanges } from "../context";
|
import { useUnsavedChanges } from "../context";
|
||||||
import type { ThemePreference } from "../hooks";
|
|
||||||
import { useTheme } from "../hooks";
|
import { useTheme } from "../hooks";
|
||||||
import { useAuth } from "./Auth";
|
import { useAuth } from "./Auth";
|
||||||
|
|
||||||
@@ -74,7 +73,7 @@ export function AppHeader({ onOpenProfile, onOpenAbout }: AppHeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div className="hero-title">
|
<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>
|
<div>
|
||||||
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
<p className="eyebrow">{pageInfo.eyebrow}</p>
|
||||||
<h1>{pageInfo.title}</h1>
|
<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 { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { log } from "../utils/logger";
|
||||||
import { ConfirmModal } from "./ConfirmModal";
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
import { PasswordInput } from "./PasswordInput";
|
import { PasswordInput } from "./PasswordInput";
|
||||||
@@ -60,7 +64,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [authState, setAuthState] = useState<AuthState | null>(null);
|
const [authState, setAuthState] = useState<AuthState | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Track if initial fetch has been done to prevent duplicate calls
|
// Track if initial fetch has been done to prevent duplicate calls
|
||||||
const initialFetchDone = useRef(false);
|
const initialFetchDone = useRef(false);
|
||||||
|
|
||||||
@@ -70,7 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
initialFetchDone.current = true;
|
initialFetchDone.current = true;
|
||||||
fetchAuthState();
|
fetchAuthState();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [fetchAuthState]);
|
||||||
|
|
||||||
// Proactively refresh token every 10 minutes to prevent expiration
|
// Proactively refresh token every 10 minutes to prevent expiration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,15 +92,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => clearInterval(refreshInterval);
|
return () => clearInterval(refreshInterval);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, authState?.authEnabled]);
|
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
|
||||||
|
|
||||||
async function fetchAuthState(retryCount = 0) {
|
async function fetchAuthState(retryCount = 0) {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
const retryDelay = 1000; // 1 second
|
const retryDelay = 1000; // 1 second
|
||||||
|
let correlationId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAuthError(null);
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`Server error: ${res.status}`);
|
throw new Error(`Server error: ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -110,7 +116,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} 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)
|
// Retry on connection errors or 5xx errors (server might be restarting)
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
@@ -125,27 +133,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
async function refreshUser() {
|
async function refreshUser() {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const userData = await res.json();
|
const userData = await res.json();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
// Access token expired - try to refresh it
|
// Access token expired - try to refresh it
|
||||||
|
log.info("[Auth] Access token invalid, attempting refresh", { correlationId });
|
||||||
const refreshed = await tryRefreshToken();
|
const refreshed = await tryRefreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
// Retry /auth/me with new token
|
// 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) {
|
if (retryRes.ok) {
|
||||||
const userData = await retryRes.json();
|
const userData = await retryRes.json();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
log.info("[Auth] Session restored after token refresh", {
|
||||||
|
userId: userData.id,
|
||||||
|
correlationId: retry.correlationId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} else {
|
} else {
|
||||||
|
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.error("[Auth] Failed to refresh user", { error });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,31 +172,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Try to refresh the access token using the refresh token
|
// Try to refresh the access token using the refresh token
|
||||||
async function tryRefreshToken(): Promise<boolean> {
|
async function tryRefreshToken(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/refresh", {
|
const { correlationId, init } = withCorrelation(
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
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;
|
return res.ok;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.error("[Auth] Token refresh request failed", { error });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string, rememberMe: boolean = false) {
|
async function login(username: string, password: string, rememberMe: boolean = false) {
|
||||||
const res = await fetch("/api/auth/login", {
|
const { correlationId, init } = withCorrelation(
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ username, password, rememberMe }),
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
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");
|
throw new Error(data.error || "Login failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username: string, password: string) {
|
async function register(username: string, password: string) {
|
||||||
@@ -201,11 +235,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", {
|
const { correlationId, init } = withCorrelation(
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
},
|
||||||
|
"fe-auth-logout"
|
||||||
|
);
|
||||||
|
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||||
|
await fetch("/api/auth/logout", init);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
log.info("[Auth] Logout completed", { correlationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(data: { currentPassword?: string; newPassword?: string }) {
|
async function updateProfile(data: { currentPassword?: string; newPassword?: string }) {
|
||||||
@@ -236,8 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
let code = "UNKNOWN";
|
||||||
throw new Error(err.error || "Upload failed");
|
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();
|
await refreshUser();
|
||||||
@@ -574,34 +622,32 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const [avatarError, setAvatarError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Close on Escape key
|
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
setAvatarError(t("form.imageUploadErrors.tooLarge"));
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAvatarLoading(true);
|
setAvatarLoading(true);
|
||||||
setError("");
|
setAvatarError("");
|
||||||
try {
|
try {
|
||||||
await uploadAvatar(file);
|
await uploadAvatar(file);
|
||||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
setAvatarError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Upload failed");
|
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||||
|
setAvatarError(resolveImageUploadError(code, t));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -610,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
async function handleAvatarDelete() {
|
async function handleAvatarDelete() {
|
||||||
setAvatarLoading(true);
|
setAvatarLoading(true);
|
||||||
setError("");
|
setAvatarError("");
|
||||||
try {
|
try {
|
||||||
await deleteAvatar();
|
await deleteAvatar();
|
||||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
setAvatarError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Delete failed");
|
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||||
|
setAvatarError(resolveImageUploadError(code, t));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
}
|
}
|
||||||
@@ -710,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="profile-username">{user.username}</span>
|
<span className="profile-username">{user.username}</span>
|
||||||
|
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleUpdate} className="profile-form">
|
<form onSubmit={handleUpdate} className="profile-form">
|
||||||
@@ -756,7 +804,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="profile-actions">
|
<div className="profile-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
{t("common.cancel", "Cancel")}
|
{t("common.close", "Close")}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// ConfirmModal Component - Simple confirmation dialog
|
// ConfirmModal Component - Simple confirmation dialog
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import { type ReactNode, useEffect } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
|
||||||
export interface ConfirmModalProps {
|
export interface ConfirmModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -12,7 +13,7 @@ export interface ConfirmModalProps {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
confirmVariant?: "primary" | "danger" | "success";
|
confirmVariant?: "primary" | "danger" | "success" | "warning";
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,20 +28,24 @@ export function ConfirmModal({
|
|||||||
confirmVariant = "primary",
|
confirmVariant = "primary",
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
}: ConfirmModalProps) {
|
}: ConfirmModalProps) {
|
||||||
// Close on Escape key
|
useEscapeKey(true, onCancel);
|
||||||
useEffect(() => {
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
<div
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||||
|
onClick={onCancel}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content confirm-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: "450px" }}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onCancel}>
|
<button className="modal-close" onClick={onCancel}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export function DateInput({ value, placeholder, className, ...rest }: DateInputP
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
<div
|
||||||
|
className={`date-input-wrapper ${className ?? ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="date-input-display" aria-hidden="true">
|
<span className="date-input-display" aria-hidden="true">
|
||||||
{displayValue || placeholder || ""}
|
{displayValue || placeholder || ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export function DateTimeInput({ value, placeholder, className, ...rest }: DateTi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
<div
|
||||||
|
className={`date-input-wrapper ${className ?? ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="date-input-display" aria-hidden="true">
|
<span className="date-input-display" aria-hidden="true">
|
||||||
{displayValue || placeholder || ""}
|
{displayValue || placeholder || ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -10,11 +12,27 @@ interface ExportModalProps {
|
|||||||
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
export default function ExportModal({ isOpen, onClose, onExport, exporting }: ExportModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useScrollLock(isOpen);
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{ maxWidth: "450px" }}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -53,7 +71,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{t("exportImport.cancelButton")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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,6 +3,7 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
|
||||||
export interface LightboxProps {
|
export interface LightboxProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -11,6 +12,8 @@ export interface LightboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||||
|
useEscapeKey(true, onClose);
|
||||||
|
|
||||||
function handleOverlayClick(e: MouseEvent) {
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
@@ -19,12 +22,26 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="lightbox-container">
|
||||||
<button className="lightbox-close" onClick={onClose}>
|
<button className="lightbox-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="lightbox-image"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
|||||||
// MedicationAvatar Component
|
// MedicationAvatar Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export type MedicationAvatarProps = {
|
export type MedicationAvatarProps = {
|
||||||
name: string;
|
name: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -9,6 +11,12 @@ export type MedicationAvatarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThumbFailed(false);
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
const initials =
|
const initials =
|
||||||
name
|
name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -19,7 +27,26 @@ export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvat
|
|||||||
const sizeClass = `med-avatar med-avatar-${size}`;
|
const sizeClass = `med-avatar med-avatar-${size}`;
|
||||||
|
|
||||||
if (imageUrl) {
|
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>;
|
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||||
* Handles new medication creation and editing existing medications
|
* Handles new medication creation and editing existing medications
|
||||||
*/
|
*/
|
||||||
import { useEffect } from "react";
|
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
|
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||||
import { DOSE_UNITS } from "../types";
|
import { DOSE_UNITS } from "../types";
|
||||||
import { deriveTotal } from "../utils";
|
import { deriveTotal } from "../utils";
|
||||||
import { DateInput } from "./DateInput";
|
import { DateInput } from "./DateInput";
|
||||||
|
import { FormNumberStepper } from "./FormNumberStepper";
|
||||||
|
|
||||||
// Field limits for validation
|
// Field limits for validation
|
||||||
const FIELD_LIMITS = {
|
const FIELD_LIMITS = {
|
||||||
@@ -17,6 +23,9 @@ const FIELD_LIMITS = {
|
|||||||
notes: { max: 1000 },
|
notes: { max: 1000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
||||||
|
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
|
||||||
|
|
||||||
export interface MobileEditModalProps {
|
export interface MobileEditModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
editingId: number | null;
|
editingId: number | null;
|
||||||
@@ -46,19 +55,11 @@ export interface MobileEditModalProps {
|
|||||||
onRemoveIntake: (idx: number) => void;
|
onRemoveIntake: (idx: number) => void;
|
||||||
// Value change handler for numeric fields
|
// Value change handler for numeric fields
|
||||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
||||||
// Refill state (for edit mode)
|
|
||||||
refillPacks: number;
|
|
||||||
onRefillPacksChange: (value: number) => void;
|
|
||||||
refillLoose: number;
|
|
||||||
onRefillLooseChange: (value: number) => void;
|
|
||||||
usePrescriptionRefill: boolean;
|
|
||||||
onUsePrescriptionRefillChange: (value: boolean) => void;
|
|
||||||
refillSaving: boolean;
|
|
||||||
onSubmitRefill: (medId: number) => Promise<void>;
|
|
||||||
// Image handling
|
// Image handling
|
||||||
meds: Medication[];
|
meds: Medication[];
|
||||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||||
|
imageUploadError: string | null;
|
||||||
// Actions
|
// Actions
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onResetForm: () => void;
|
onResetForm: () => void;
|
||||||
@@ -74,8 +75,7 @@ function deriveTotalFromForm(form: FormState) {
|
|||||||
const packCount = Number(form.packCount) || 0;
|
const packCount = Number(form.packCount) || 0;
|
||||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
const looseTablets = Number(form.looseTablets) || 0;
|
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, 0);
|
||||||
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileEditModal({
|
export function MobileEditModal({
|
||||||
@@ -96,57 +96,195 @@ export function MobileEditModal({
|
|||||||
onAddTakenByPerson,
|
onAddTakenByPerson,
|
||||||
onRemoveTakenByPerson,
|
onRemoveTakenByPerson,
|
||||||
onTakenByKeyDown,
|
onTakenByKeyDown,
|
||||||
onSetBlisterValue,
|
_onSetBlisterValue,
|
||||||
onAddBlister,
|
_onAddBlister,
|
||||||
onRemoveBlister,
|
_onRemoveBlister,
|
||||||
onSetIntakeValue,
|
onSetIntakeValue,
|
||||||
onAddIntake,
|
onAddIntake,
|
||||||
onRemoveIntake,
|
onRemoveIntake,
|
||||||
onHandleValueChange,
|
onHandleValueChange,
|
||||||
refillPacks,
|
|
||||||
onRefillPacksChange,
|
|
||||||
refillLoose,
|
|
||||||
onRefillLooseChange,
|
|
||||||
usePrescriptionRefill,
|
|
||||||
onUsePrescriptionRefillChange,
|
|
||||||
refillSaving,
|
|
||||||
onSubmitRefill,
|
|
||||||
meds,
|
meds,
|
||||||
onUploadMedImage,
|
onUploadMedImage,
|
||||||
onDeleteMedImage,
|
onDeleteMedImage,
|
||||||
|
imageUploadError,
|
||||||
onClose,
|
onClose,
|
||||||
_onResetForm,
|
_onResetForm,
|
||||||
onSaveMedication,
|
onSaveMedication,
|
||||||
}: MobileEditModalProps) {
|
}: MobileEditModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const decrementValueLabel = t("editStock.decreaseValue");
|
||||||
|
const incrementValueLabel = t("editStock.increaseValue");
|
||||||
|
const [activeTab, setActiveTab] = useState<MobileTab>("general");
|
||||||
|
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
||||||
|
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tabViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const swipeAxisRef = useRef<"x" | "y" | null>(null);
|
||||||
|
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
|
||||||
|
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
|
||||||
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
|
const activeTabIndexRef = useRef(0);
|
||||||
|
|
||||||
// Close on Escape key
|
// Reset tab when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return;
|
if (show) {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
setActiveTab("general");
|
||||||
if (e.key === "Escape") {
|
setShowNameValidation(false);
|
||||||
onClose();
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && (hasValidationErrors || !!fieldErrors.name)) {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
}
|
||||||
|
}, [show, hasValidationErrors, fieldErrors.name]);
|
||||||
|
|
||||||
|
useEscapeKey(show, onClose);
|
||||||
|
|
||||||
|
// Lock background scroll while modal is open.
|
||||||
|
useScrollLock(show);
|
||||||
|
|
||||||
|
// Keep activeTabIndex ref in sync for native listeners
|
||||||
|
const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab);
|
||||||
|
activeTabIndexRef.current = activeTabIndex;
|
||||||
|
|
||||||
|
// Auto-scroll tab strip to keep active tab visible
|
||||||
|
useEffect(() => {
|
||||||
|
const strip = tabStripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const btn = strip.children[activeTabIndex] as HTMLElement | undefined;
|
||||||
|
if (btn) btn.scrollIntoView?.({ behavior: "smooth", inline: "nearest", block: "nearest" });
|
||||||
|
}, [activeTabIndex]);
|
||||||
|
|
||||||
|
// Non-passive touch listeners for reliable horizontal swipe detection.
|
||||||
|
// React's onTouchMove is passive, so e.preventDefault() is a no-op there.
|
||||||
|
// With native { passive: false } we can block the browser's vertical scroll
|
||||||
|
// when a horizontal swipe is detected, making tab swiping reliable.
|
||||||
|
useEffect(() => {
|
||||||
|
const fieldset = fieldsetRef.current;
|
||||||
|
if (!show || !fieldset) return;
|
||||||
|
|
||||||
|
const AXIS_LOCK_THRESHOLD = 6;
|
||||||
|
|
||||||
|
function resetSwipe() {
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
swipeAxisRef.current = null;
|
||||||
|
setIsHorizontalSwiping(false);
|
||||||
|
setSwipeDeltaX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const touch = e.touches[0];
|
||||||
|
swipeStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
swipeAxisRef.current = null;
|
||||||
|
setIsHorizontalSwiping(false);
|
||||||
|
setSwipeDeltaX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!swipeStartRef.current || e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - swipeStartRef.current.x;
|
||||||
|
const dy = touch.clientY - swipeStartRef.current.y;
|
||||||
|
const ax = Math.abs(dx);
|
||||||
|
const ay = Math.abs(dy);
|
||||||
|
|
||||||
|
if (!swipeAxisRef.current) {
|
||||||
|
if (ax < AXIS_LOCK_THRESHOLD && ay < AXIS_LOCK_THRESHOLD) return;
|
||||||
|
swipeAxisRef.current = ax >= ay ? "x" : "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swipeAxisRef.current === "y") return;
|
||||||
|
|
||||||
|
// Horizontal swipe — block native vertical scroll
|
||||||
|
e.preventDefault();
|
||||||
|
setIsHorizontalSwiping(true);
|
||||||
|
|
||||||
|
let nextDelta = dx;
|
||||||
|
const idx = activeTabIndexRef.current;
|
||||||
|
if ((idx === 0 && nextDelta > 0) || (idx === MOBILE_TAB_ORDER.length - 1 && nextDelta < 0)) {
|
||||||
|
nextDelta *= 0.35;
|
||||||
|
}
|
||||||
|
setSwipeDeltaX(nextDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e: TouchEvent) {
|
||||||
|
if (!swipeStartRef.current || e.changedTouches.length !== 1) {
|
||||||
|
resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swipeAxisRef.current === "x") {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const dx = touch.clientX - swipeStartRef.current.x;
|
||||||
|
const minSwipe = Math.max(36, (tabViewportRef.current?.clientWidth ?? 360) * 0.1);
|
||||||
|
if (Math.abs(dx) >= minSwipe) {
|
||||||
|
const direction = dx < 0 ? 1 : -1;
|
||||||
|
const idx = activeTabIndexRef.current;
|
||||||
|
const next = Math.min(Math.max(idx + direction, 0), MOBILE_TAB_ORDER.length - 1);
|
||||||
|
if (next !== idx) {
|
||||||
|
setActiveTab(MOBILE_TAB_ORDER[next]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
resetSwipe();
|
||||||
}, [show, onClose]);
|
}
|
||||||
|
|
||||||
|
fieldset.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
fieldset.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||||
|
fieldset.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||||
|
fieldset.addEventListener("touchcancel", resetSwipe, { passive: true });
|
||||||
|
return () => {
|
||||||
|
fieldset.removeEventListener("touchstart", onTouchStart);
|
||||||
|
fieldset.removeEventListener("touchmove", onTouchMove);
|
||||||
|
fieldset.removeEventListener("touchend", onTouchEnd);
|
||||||
|
fieldset.removeEventListener("touchcancel", resetSwipe);
|
||||||
|
};
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||||
|
const mobileTitle = (() => {
|
||||||
|
if (!editingId) return t("form.newEntry");
|
||||||
|
if (readOnlyMode) return t("form.viewEntry");
|
||||||
|
const medicationName = currentMed?.name?.trim() || form.name.trim();
|
||||||
|
if (!medicationName) return t("form.editEntry");
|
||||||
|
return t("form.editEntryWithName", { name: medicationName });
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay mobile-edit-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content edit-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="edit-modal-header">
|
<div className="edit-modal-header">
|
||||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||||
← {t("common.back")}
|
← {t("common.back")}
|
||||||
</button>
|
</button>
|
||||||
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2>
|
<h2>{mobileTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
className="form-grid mobile-edit-form"
|
className="form-grid mobile-edit-form"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
// Check native HTML5 validation first
|
// Check native HTML5 validation first
|
||||||
const formElement = e.currentTarget;
|
const formElement = e.currentTarget;
|
||||||
@@ -159,19 +297,75 @@ export function MobileEditModal({
|
|||||||
onSaveMedication(e);
|
onSaveMedication(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
|
<div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")} ref={tabStripRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "general"}
|
||||||
|
className={`form-tab${activeTab === "general" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("general")}
|
||||||
|
>
|
||||||
|
{t("form.sections.general")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "stock"}
|
||||||
|
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("stock")}
|
||||||
|
>
|
||||||
|
{t("form.sections.stock")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "schedule"}
|
||||||
|
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("schedule")}
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
ref={fieldsetRef}
|
||||||
|
className={`readonly-fieldset${isHorizontalSwiping ? " swiping-horizontal" : ""}`}
|
||||||
|
disabled={readOnlyMode}
|
||||||
|
>
|
||||||
|
<div className="mobile-tab-viewport" ref={tabViewportRef}>
|
||||||
|
<div
|
||||||
|
className={`mobile-tab-track${isHorizontalSwiping ? " is-swiping" : ""}`}
|
||||||
|
style={{ transform: `translateX(calc(${-activeTabIndex * 100}% + ${swipeDeltaX}px))` }}
|
||||||
|
>
|
||||||
|
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
<label
|
||||||
|
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.name ? "has-error" : ""}`}
|
||||||
|
>
|
||||||
{t("form.commercialName")}
|
{t("form.commercialName")}
|
||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
onFormChange({ ...form, name: e.target.value });
|
||||||
|
}}
|
||||||
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.commercial")}
|
placeholder={t("form.placeholders.commercial")}
|
||||||
maxLength={FIELD_LIMITS.name.max}
|
maxLength={FIELD_LIMITS.name.max}
|
||||||
required={!readOnlyMode}
|
required={!readOnlyMode}
|
||||||
/>
|
/>
|
||||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
{!readOnlyMode && showNameValidation && fieldErrors.name && (
|
||||||
|
<span className="field-error">{fieldErrors.name}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||||
{t("form.genericName")}
|
{t("form.genericName")}
|
||||||
@@ -189,7 +383,20 @@ export function MobileEditModal({
|
|||||||
value={form.medicationStartDate}
|
value={form.medicationStartDate}
|
||||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
{!readOnlyMode && dateConsistencyError && (
|
||||||
|
<span className="field-error">{dateConsistencyError}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageType")}
|
||||||
|
<select
|
||||||
|
className="package-type-select"
|
||||||
|
value={form.packageType}
|
||||||
|
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||||
{t("form.takenBy")}
|
{t("form.takenBy")}
|
||||||
@@ -210,7 +417,9 @@ export function MobileEditModal({
|
|||||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
form.takenBy.length === 0
|
||||||
|
? t("form.placeholders.takenBy")
|
||||||
|
: t("form.placeholders.addPerson")
|
||||||
}
|
}
|
||||||
maxLength={FIELD_LIMITS.takenBy.max}
|
maxLength={FIELD_LIMITS.takenBy.max}
|
||||||
list="takenby-suggestions-modal"
|
list="takenby-suggestions-modal"
|
||||||
@@ -225,94 +434,113 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
|
||||||
{t("form.packageType")}
|
|
||||||
<select
|
|
||||||
className="package-type-select"
|
|
||||||
value={form.packageType}
|
|
||||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingId && (
|
||||||
|
<div className="full form-category image-section">
|
||||||
|
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
||||||
|
{currentMed?.imageUrl ? (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => onDeleteMedImage(editingId)}
|
||||||
|
aria-label={t("form.removeImage")}
|
||||||
|
data-tooltip={t("form.removeImage")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) void onUploadMedImage(editingId, file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{form.packageType === "blister" ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.packs")}
|
{t("form.packs")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.packCount}
|
value={form.packCount}
|
||||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blistersPerPack")}
|
{t("form.blistersPerPack")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.blistersPerPack}
|
value={form.blistersPerPack}
|
||||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.pillsPerBlister")}
|
{t("form.pillsPerBlister")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.pillsPerBlister}
|
value={form.pillsPerBlister}
|
||||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.loosePills")}
|
{t("form.total")}
|
||||||
<input
|
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.totalCapacity")}
|
{t("form.totalCapacity")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.totalPills}
|
value={form.totalPills}
|
||||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.currentPills")}
|
{t("form.currentPills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
value={form.looseTablets}
|
||||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="full">
|
{form.packageType === "bottle" && (
|
||||||
|
<div className="full stock-total-row">
|
||||||
|
<div className="stock-total-field">
|
||||||
<p className="sub">
|
<p className="sub">
|
||||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<div className="dose-input-group">
|
<div className="dose-input-group">
|
||||||
@@ -360,210 +588,57 @@ export function MobileEditModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{form.notes.length > 0 && (
|
{form.notes.length > 0 && (
|
||||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
<span
|
||||||
|
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
|
||||||
|
>
|
||||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="full form-category">
|
|
||||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
|
||||||
<label className="full">
|
|
||||||
{t("prescription.enabled")}
|
|
||||||
<label className="toggle-switch small">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.prescriptionEnabled}
|
|
||||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</label>
|
|
||||||
{form.prescriptionEnabled && (
|
|
||||||
<>
|
|
||||||
<label className="prescription-field">
|
|
||||||
{t("prescription.authorizedRefills")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionAuthorizedRefills}
|
|
||||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="prescription-field">
|
|
||||||
{t("prescription.remainingRefills")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionRemainingRefills}
|
|
||||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="prescription-field">
|
|
||||||
{t("prescription.lowThreshold")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionLowRefillThreshold}
|
|
||||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="prescription-field">
|
|
||||||
{t("prescription.expiryDate")}
|
|
||||||
<DateInput
|
|
||||||
value={form.prescriptionExpiryDate}
|
|
||||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
|
||||||
{!readOnlyMode && (
|
|
||||||
<div className="full form-category refill-section">
|
|
||||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
|
||||||
{editingId ? (
|
|
||||||
<>
|
|
||||||
{form.packageType === "blister" ? (
|
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
{t("refill.packs")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillPacks}
|
|
||||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("refill.loosePills")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<label className="full">
|
|
||||||
{t("refill.pillsToAdd")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="refill-submit-row full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="success"
|
|
||||||
onClick={() => onSubmitRefill(editingId)}
|
|
||||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
|
||||||
>
|
|
||||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
|
||||||
</button>
|
|
||||||
{(() => {
|
|
||||||
const totalRefill =
|
|
||||||
form.packageType === "blister"
|
|
||||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
|
||||||
refillLoose
|
|
||||||
: refillLoose;
|
|
||||||
return totalRefill > 0 ? (
|
|
||||||
<span className="refill-preview">
|
|
||||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{form.prescriptionEnabled && (
|
|
||||||
<div className="refill-prescription-row full">
|
|
||||||
<label className="refill-prescription-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={usePrescriptionRefill}
|
|
||||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
|
||||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
|
||||||
/>
|
|
||||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
|
||||||
</label>
|
|
||||||
<span className="refill-remaining-badge">
|
|
||||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="refill-unavailable">
|
|
||||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingId && (
|
|
||||||
<div className="full form-category image-section">
|
|
||||||
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
|
||||||
{currentMed?.imageUrl ? (
|
|
||||||
<div className="image-preview">
|
|
||||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
|
||||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
|
||||||
{t("form.removeImage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="full form-category intake-section">
|
<div className="full form-category intake-section">
|
||||||
<div className="form-category-header">
|
<div className="form-category-header">
|
||||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||||
{!readOnlyMode && (
|
{!readOnlyMode && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost add-blister"
|
className="ghost add-blister icon-only tooltip-trigger"
|
||||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||||
|
aria-label={t("form.blisters.addIntake")}
|
||||||
|
data-tooltip={t("form.blisters.addIntake")}
|
||||||
>
|
>
|
||||||
+ {t("form.blisters.addIntake")}
|
<Plus size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{form.intakes.map((intake, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div key={idx} className="blister-row">
|
<div
|
||||||
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
||||||
|
className="blister-row"
|
||||||
|
>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.usage")}</span>
|
<span>{t("form.blisters.usage")}</span>
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||||
|
min={0.5}
|
||||||
|
step={0.5}
|
||||||
|
allowDecimal={true}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.everyDays")}</span>
|
<span>{t("form.blisters.everyDays")}</span>
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={intake.every}
|
value={intake.every}
|
||||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact full-row">
|
<label className="compact full-row">
|
||||||
@@ -582,9 +657,12 @@ export function MobileEditModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.takenBy.length === 0 ? null : (
|
{form.takenBy.length === 0 ? null : (
|
||||||
<label className="compact full-row">
|
<label className="compact full-row taken-by-field">
|
||||||
<span>{t("form.blisters.takenByIntake")}</span>
|
<span>{t("form.blisters.takenByIntake")}</span>
|
||||||
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
<select
|
||||||
|
value={intake.takenBy}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
|
||||||
|
>
|
||||||
{form.takenBy.map((person) => (
|
{form.takenBy.map((person) => (
|
||||||
<option key={person} value={person}>
|
<option key={person} value={person}>
|
||||||
{person}
|
{person}
|
||||||
@@ -594,7 +672,9 @@ export function MobileEditModal({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||||
<span className="legend-hint">🔔</span>
|
<span className="legend-hint">
|
||||||
|
<Bell size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
<label className="toggle-switch small">
|
<label className="toggle-switch small">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -605,17 +685,83 @@ export function MobileEditModal({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{!readOnlyMode && form.intakes.length > 1 && (
|
{!readOnlyMode && form.intakes.length > 1 && (
|
||||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
<button
|
||||||
{t("common.remove")}
|
type="button"
|
||||||
|
className="danger remove-blister-btn icon-only tooltip-trigger"
|
||||||
|
onClick={() => onRemoveIntake(idx)}
|
||||||
|
aria-label={t("common.remove")}
|
||||||
|
data-tooltip={t("common.remove")}
|
||||||
|
>
|
||||||
|
<Minus size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
|
||||||
|
<div className="full form-category">
|
||||||
|
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||||
|
<label className="full">
|
||||||
|
{t("prescription.enabled")}
|
||||||
|
<span className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.prescriptionEnabled}
|
||||||
|
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{form.prescriptionEnabled && (
|
||||||
|
<>
|
||||||
|
<label className="prescription-field">
|
||||||
|
{t("prescription.authorizedRefills")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.prescriptionAuthorizedRefills}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="prescription-field">
|
||||||
|
{t("prescription.remainingRefills")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.prescriptionRemainingRefills}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="prescription-field">
|
||||||
|
{t("prescription.lowThreshold")}
|
||||||
|
<FormNumberStepper
|
||||||
|
value={form.prescriptionLowRefillThreshold}
|
||||||
|
onChange={(nextValue) => onHandleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="prescription-field">
|
||||||
|
{t("prescription.expiryDate")}
|
||||||
|
<DateInput
|
||||||
|
value={form.prescriptionExpiryDate}
|
||||||
|
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{readOnlyMode ? t("common.close") : t("common.cancel")}
|
{readOnlyMode || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{!readOnlyMode && (
|
{!readOnlyMode && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import { UserProfile } from "./Auth";
|
import { UserProfile } from "./Auth";
|
||||||
|
|
||||||
interface ProfileModalProps {
|
interface ProfileModalProps {
|
||||||
@@ -6,11 +7,25 @@ interface ProfileModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content profile-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
|
|
||||||
|
interface ReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
medications: Medication[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportData = Record<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
|
dosesDismissed: number;
|
||||||
|
firstDoseAt: string | null;
|
||||||
|
lastDoseAt: string | null;
|
||||||
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [format, setFormat] = useState<ReportFormat>("pdf");
|
||||||
|
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>();
|
||||||
|
for (const med of medications) {
|
||||||
|
if (med.takenBy) {
|
||||||
|
for (const p of med.takenBy) people.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(people).sort();
|
||||||
|
}, [medications]);
|
||||||
|
|
||||||
|
// Filtered medications based on takenBy filter
|
||||||
|
const filteredMeds = useMemo(() => {
|
||||||
|
if (takenByFilter.size === 0) return medications;
|
||||||
|
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
|
||||||
|
}, [medications, takenByFilter]);
|
||||||
|
|
||||||
|
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
|
||||||
|
const obsoleteMeds = useMemo(() => filteredMeds.filter((m) => m.isObsolete), [filteredMeds]);
|
||||||
|
|
||||||
|
const togglePerson = useCallback((person: string) => {
|
||||||
|
setTakenByFilter((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(person)) next.delete(person);
|
||||||
|
else next.add(person);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAllPeople = useCallback(() => {
|
||||||
|
setTakenByFilter(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset selection when modal opens or filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
|
||||||
|
}
|
||||||
|
}, [isOpen, filteredMeds]);
|
||||||
|
|
||||||
|
// Reset everything when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTakenByFilter(new Set());
|
||||||
|
setFormat("pdf");
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const toggleMed = useCallback((id: number) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
|
||||||
|
}, [filteredMeds]);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch report data from backend
|
||||||
|
const res = await fetch("/api/medications/report-data", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||||
|
const reportData = (await res.json()) as ReportData;
|
||||||
|
|
||||||
|
if (format === "pdf") {
|
||||||
|
const imageMap = await fetchMedImages(selectedMeds);
|
||||||
|
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||||
|
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
|
||||||
|
} else {
|
||||||
|
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||||
|
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
|
||||||
|
downloadFile(content, format);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Stay open on error so user can retry
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content report-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 className="report-modal-title">{t("report.title")}</h2>
|
||||||
|
<p className="report-modal-desc">{t("report.description")}</p>
|
||||||
|
|
||||||
|
{/* Person filter */}
|
||||||
|
{allPeople.length > 1 && (
|
||||||
|
<div className="report-person-filter">
|
||||||
|
<h4>{t("report.filterByPerson")}</h4>
|
||||||
|
<div className="report-format-options">
|
||||||
|
<label className={`report-format-option${takenByFilter.size === 0 ? " selected" : ""}`}>
|
||||||
|
<input type="checkbox" checked={takenByFilter.size === 0} onChange={selectAllPeople} />
|
||||||
|
<span>{t("report.allPeople")}</span>
|
||||||
|
</label>
|
||||||
|
{allPeople.map((person) => (
|
||||||
|
<label key={person} className={`report-format-option${takenByFilter.has(person) ? " selected" : ""}`}>
|
||||||
|
<input type="checkbox" checked={takenByFilter.has(person)} onChange={() => togglePerson(person)} />
|
||||||
|
<span>{person}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Medication selection */}
|
||||||
|
<div className="report-selection">
|
||||||
|
<div className="report-selection-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={selectedIds.size === filteredMeds.length ? deselectAll : selectAll}
|
||||||
|
>
|
||||||
|
{selectedIds.size === filteredMeds.length ? t("report.deselectAll") : t("report.selectAll")}
|
||||||
|
</button>
|
||||||
|
<span className="report-selection-count">
|
||||||
|
{selectedIds.size} / {filteredMeds.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeMeds.length > 0 && (
|
||||||
|
<div className="report-group">
|
||||||
|
<h4 className="report-group-title">{t("report.activeMeds")}</h4>
|
||||||
|
<div className="report-med-list">
|
||||||
|
{activeMeds.map((med) => (
|
||||||
|
<label key={med.id} className="report-med-item">
|
||||||
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
|
<span className="report-med-name">
|
||||||
|
{med.name}
|
||||||
|
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{obsoleteMeds.length > 0 && (
|
||||||
|
<div className="report-group">
|
||||||
|
<h4 className="report-group-title">{t("report.obsoleteMeds")}</h4>
|
||||||
|
<div className="report-med-list">
|
||||||
|
{obsoleteMeds.map((med) => (
|
||||||
|
<label key={med.id} className="report-med-item">
|
||||||
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
|
<span className="report-med-name obsolete-name">
|
||||||
|
{med.name}
|
||||||
|
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format selection */}
|
||||||
|
<div className="report-format">
|
||||||
|
<h4>{t("report.format")}</h4>
|
||||||
|
<div className="report-format-options">
|
||||||
|
<label className={`report-format-option${format === "pdf" ? " selected" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="pdf"
|
||||||
|
checked={format === "pdf"}
|
||||||
|
onChange={() => setFormat("pdf")}
|
||||||
|
/>
|
||||||
|
<span>{t("report.formatPdf")}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`report-format-option${format === "txt" ? " selected" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="txt"
|
||||||
|
checked={format === "txt"}
|
||||||
|
onChange={() => setFormat("txt")}
|
||||||
|
/>
|
||||||
|
<span>{t("report.formatTxt")}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`report-format-option${format === "md" ? " selected" : ""}`}>
|
||||||
|
<input type="radio" name="format" value="md" checked={format === "md"} onChange={() => setFormat("md")} />
|
||||||
|
<span>{t("report.formatMd")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="report-actions">
|
||||||
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={selectedIds.size === 0 || generating}
|
||||||
|
>
|
||||||
|
{generating ? t("report.generating") : t("report.generate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Report generation helpers ───
|
||||||
|
|
||||||
|
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (!m) return "-";
|
||||||
|
return `${m[3]}.${m[2]}.${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
|
if (!m) return `${fmtDate(iso)}`;
|
||||||
|
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTextReport(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
fmt: "txt" | "md",
|
||||||
|
t: TFn,
|
||||||
|
personFilter: string[] | null
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const sep = fmt === "md" ? "---" : "═".repeat(60);
|
||||||
|
const h1 = (s: string) => (fmt === "md" ? `# ${s}` : s);
|
||||||
|
const h2 = (s: string) => (fmt === "md" ? `## ${s}` : s);
|
||||||
|
const h3 = (s: string) => (fmt === "md" ? `### ${s}` : ` ${s}`);
|
||||||
|
const bold = (s: string) => (fmt === "md" ? `**${s}**` : s);
|
||||||
|
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
|
||||||
|
|
||||||
|
lines.push(h1(t("report.docTitle")));
|
||||||
|
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (const med of meds) {
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push("");
|
||||||
|
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
|
||||||
|
lines.push(h2(title));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// General
|
||||||
|
lines.push(h3(t("report.docGeneral")));
|
||||||
|
lines.push(item(t("report.docCommercialName"), med.name));
|
||||||
|
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
|
||||||
|
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
|
||||||
|
lines.push(
|
||||||
|
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
|
||||||
|
);
|
||||||
|
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
|
||||||
|
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Package / Stock
|
||||||
|
lines.push(h3(t("report.docPackage")));
|
||||||
|
lines.push(
|
||||||
|
item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))
|
||||||
|
);
|
||||||
|
if (med.packageType === "blister") {
|
||||||
|
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
||||||
|
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
||||||
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
|
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||||
|
} else {
|
||||||
|
lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets)));
|
||||||
|
}
|
||||||
|
lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`));
|
||||||
|
if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||||
|
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||||
|
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Intake Schedule
|
||||||
|
const allIntakes = med.intakes ?? med.blisters;
|
||||||
|
const intakes = personFilter
|
||||||
|
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||||
|
: allIntakes;
|
||||||
|
if (intakes?.length) {
|
||||||
|
lines.push(h3(t("report.docIntakeSchedule")));
|
||||||
|
for (const intake of intakes) {
|
||||||
|
let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
||||||
|
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
||||||
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
|
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
|
||||||
|
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||||
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prescription
|
||||||
|
if (med.prescriptionEnabled) {
|
||||||
|
lines.push(h3(t("report.docPrescription")));
|
||||||
|
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
|
||||||
|
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
|
||||||
|
if (med.prescriptionExpiryDate)
|
||||||
|
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dose tracking data
|
||||||
|
const data = reportData[med.id];
|
||||||
|
if (data) {
|
||||||
|
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)));
|
||||||
|
} else {
|
||||||
|
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Refill history
|
||||||
|
if (data.refills.length > 0) {
|
||||||
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
|
for (const r of data.refills) {
|
||||||
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`;
|
||||||
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(sep);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(content: string, format: "txt" | "md") {
|
||||||
|
const mimeType = format === "md" ? "text/markdown" : "text/plain";
|
||||||
|
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10);
|
||||||
|
a.href = url;
|
||||||
|
a.download = `medassist-report-${dateStr}.${format}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageMap = Record<number, string>;
|
||||||
|
|
||||||
|
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
|
||||||
|
const map: ImageMap = {};
|
||||||
|
const fetches = meds
|
||||||
|
.filter((m) => m.imageUrl)
|
||||||
|
.map(async (m) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
map[m.id] = dataUrl;
|
||||||
|
} catch {
|
||||||
|
// Skip image on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(fetches);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrintView(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
t: TFn,
|
||||||
|
imageMap: ImageMap,
|
||||||
|
personFilter: string[] | null
|
||||||
|
) {
|
||||||
|
const w = window.open("", "_blank");
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
|
||||||
|
w.document.write(html);
|
||||||
|
w.document.close();
|
||||||
|
w.onload = () => setTimeout(() => w.print(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrintHtml(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
t: TFn,
|
||||||
|
imageMap: ImageMap,
|
||||||
|
personFilter: string[] | null
|
||||||
|
): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
for (const med of meds) {
|
||||||
|
const data = reportData[med.id];
|
||||||
|
const intakes = med.intakes ?? med.blisters;
|
||||||
|
const title = med.isObsolete
|
||||||
|
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
|
||||||
|
: escHtml(med.name);
|
||||||
|
|
||||||
|
let s = `<div class="med-section">`;
|
||||||
|
const imgDataUrl = imageMap[med.id];
|
||||||
|
|
||||||
|
// Title with generic name subtitle
|
||||||
|
s += `<h2>${title}</h2>`;
|
||||||
|
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
|
||||||
|
|
||||||
|
// Build general info table rows
|
||||||
|
const generalRows: string[] = [];
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.genericName)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.takenBy?.length)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docTakenBy"))}</td><td>${escHtml(med.takenBy.join(", "))}</td></tr>`
|
||||||
|
);
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docStatus"))}</td><td>${escHtml(med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.medicationStartDate)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.isObsolete && med.obsoleteAt)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
|
||||||
|
);
|
||||||
|
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
|
||||||
|
|
||||||
|
if (imgDataUrl) {
|
||||||
|
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(med.name)}" /><div class="med-overview-info">${generalTable}</div></div>`;
|
||||||
|
} else {
|
||||||
|
s += generalTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package / Stock
|
||||||
|
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||||
|
s += `<table><tbody>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}</td></tr>`;
|
||||||
|
if (med.packageType === "blister") {
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
||||||
|
if (med.looseTablets > 0)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||||
|
} else {
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||||
|
}
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${getPackageSize(med)} ${escHtml(t("common.pills"))}</td></tr>`;
|
||||||
|
if (med.pillWeightMg)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||||
|
if (med.expiryDate)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||||
|
if (med.notes)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
|
||||||
|
// Intake Schedule
|
||||||
|
const allPrintIntakes = intakes;
|
||||||
|
const filteredPrintIntakes = personFilter
|
||||||
|
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||||
|
: allPrintIntakes;
|
||||||
|
if (filteredPrintIntakes?.length) {
|
||||||
|
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||||
|
s += `<ul>`;
|
||||||
|
for (const intake of filteredPrintIntakes) {
|
||||||
|
let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`;
|
||||||
|
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
||||||
|
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
||||||
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
|
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
|
||||||
|
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||||
|
s += `<li>${entry}</li>`;
|
||||||
|
}
|
||||||
|
s += `</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prescription
|
||||||
|
if (med.prescriptionEnabled) {
|
||||||
|
s += `<h3>${escHtml(t("report.docPrescription"))}</h3>`;
|
||||||
|
s += `<table><tbody>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
|
||||||
|
if (med.prescriptionExpiryDate)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intake history
|
||||||
|
if (data) {
|
||||||
|
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
||||||
|
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)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
|
||||||
|
if (data.lastDoseAt)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
} else {
|
||||||
|
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refill history
|
||||||
|
if (data.refills.length > 0) {
|
||||||
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
|
s += `<ul>`;
|
||||||
|
for (const r of data.refills) {
|
||||||
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`;
|
||||||
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
|
s += `<li>${entry}</li>`;
|
||||||
|
}
|
||||||
|
s += `</ul>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s += `</div>`;
|
||||||
|
sections.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${escHtml(t("report.docTitle"))}</title>
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; padding: 1rem; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.med-section:last-child { margin-bottom: 0; padding-bottom: 0; }
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { color: #64748b; margin-bottom: 1rem; }
|
||||||
|
.med-section { margin-bottom: 1.5rem; padding-bottom: 1rem; }
|
||||||
|
.med-section:last-child { }
|
||||||
|
h2 { font-size: 1.25rem; color: #0f172a; margin: 0; }
|
||||||
|
.generic-subtitle { margin: 0.1rem 0 0.5rem; font-size: 0.9rem; font-style: italic; color: #64748b; }
|
||||||
|
h2 + .med-overview { margin-top: 0.5rem; }
|
||||||
|
.med-overview { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
|
.med-overview-info { flex: 1; min-width: 0; }
|
||||||
|
.med-overview-info h3 { margin-top: 0; }
|
||||||
|
.med-img { width: 220px; height: 220px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
h3 { font-size: 0.9rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 0.5rem; }
|
||||||
|
td { padding: 0.25rem 0.5rem; }
|
||||||
|
td.label { font-weight: 500; color: #475569; width: 40%; }
|
||||||
|
ul { margin: 0.25rem 0; padding-left: 1.5rem; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.obsolete-badge { font-size: 0.75rem; background: #fef3c7; color: #92400e; padding: 0.125rem 0.5rem; border-radius: 4px; vertical-align: middle; }
|
||||||
|
.no-data { color: #94a3b8; font-style: italic; }
|
||||||
|
.print-hint { text-align: center; padding: 1rem; background: #f0f9ff; border-radius: 8px; color: #0369a1; margin-bottom: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
||||||
|
<h1>${escHtml(t("report.docTitle"))}</h1>
|
||||||
|
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
|
||||||
|
${sections.join("\n")}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportModal;
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
* ShareDialog - Modal for generating share links for medication schedules
|
* ShareDialog - Modal for generating share links for medication schedules
|
||||||
* Allows sharing schedule view for a specific person
|
* Allows sharing schedule view for a specific person
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Check, Copy, Link2, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
|
||||||
export interface ShareDialogProps {
|
export interface ShareDialogProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -38,26 +41,55 @@ export function ShareDialog({
|
|||||||
onCopyShareLink,
|
onCopyShareLink,
|
||||||
}: ShareDialogProps) {
|
}: ShareDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const closeLabel = t("common.close");
|
||||||
|
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||||
|
|
||||||
|
useEscapeKey(show, onClose);
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
<button className="modal-close" onClick={onClose}>
|
onClick={onClose}
|
||||||
×
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content share-dialog-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close tooltip-trigger"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={closeLabel}
|
||||||
|
data-tooltip={closeLabel}
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="share-dialog-header">
|
<div className="share-dialog-header">
|
||||||
<h2>🔗 {t("share.title")}</h2>
|
<h2>
|
||||||
|
<Link2 size={18} aria-hidden="true" /> {t("share.title")}
|
||||||
|
</h2>
|
||||||
<p className="share-dialog-description">{t("share.description")}</p>
|
<p className="share-dialog-description">{t("share.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sharePeople.length === 0 ? (
|
{(() => {
|
||||||
|
if (sharePeople.length === 0) {
|
||||||
|
return (
|
||||||
<div className="share-dialog-empty">
|
<div className="share-dialog-empty">
|
||||||
<p>{t("share.noPeople")}</p>
|
<p>{t("share.noPeople")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : shareLink ? (
|
);
|
||||||
|
}
|
||||||
|
if (shareLink) {
|
||||||
|
return (
|
||||||
<div className="share-dialog-result">
|
<div className="share-dialog-result">
|
||||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||||
<div className="share-link-box">
|
<div className="share-link-box">
|
||||||
@@ -68,8 +100,14 @@ export function ShareDialog({
|
|||||||
className="share-link-input"
|
className="share-link-input"
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
/>
|
/>
|
||||||
<button className="btn-copy" onClick={onCopyShareLink}>
|
<button
|
||||||
{shareCopied ? "✓" : "📋"}
|
type="button"
|
||||||
|
className="btn-copy icon-only tooltip-trigger"
|
||||||
|
onClick={onCopyShareLink}
|
||||||
|
aria-label={copyLabel}
|
||||||
|
data-tooltip={copyLabel}
|
||||||
|
>
|
||||||
|
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||||
@@ -86,11 +124,17 @@ export function ShareDialog({
|
|||||||
<button onClick={onClose}>{t("common.close")}</button>
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="share-dialog-form">
|
<div className="share-dialog-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPerson")}</label>
|
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
<select
|
||||||
|
id="share-person-select"
|
||||||
|
value={shareSelectedPerson}
|
||||||
|
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||||
|
>
|
||||||
{sharePeople.map((person) => (
|
{sharePeople.map((person) => (
|
||||||
<option key={person} value={person}>
|
<option key={person} value={person}>
|
||||||
{person}
|
{person}
|
||||||
@@ -100,8 +144,12 @@ export function ShareDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPeriod")}</label>
|
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
<select
|
||||||
|
id="share-period-select"
|
||||||
|
value={shareSelectedDays}
|
||||||
|
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
@@ -110,14 +158,15 @@ export function ShareDialog({
|
|||||||
|
|
||||||
<div className="share-dialog-footer">
|
<div className="share-dialog-footer">
|
||||||
<button className="ghost" onClick={onClose}>
|
<button className="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SharedSchedule Component - Public view for shared schedules
|
// 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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useEscapeKey } from "../hooks";
|
||||||
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
||||||
import { getMedTotal } from "../types";
|
import { getMedTotal } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
@@ -149,15 +152,7 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close lightbox on Escape key
|
// Close lightbox on Escape key
|
||||||
useEffect(() => {
|
useEscapeKey(!!lightboxImage, closeLightbox);
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape" && lightboxImage) {
|
|
||||||
closeLightbox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [lightboxImage, closeLightbox]);
|
|
||||||
|
|
||||||
// Handle browser back button to close lightbox
|
// Handle browser back button to close lightbox
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,7 +204,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
||||||
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
||||||
function getDoseId(doseId: string, _person: string | null): string {
|
function _getDoseId(doseId: string, _person: string | null): string {
|
||||||
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
||||||
return doseId;
|
return doseId;
|
||||||
}
|
}
|
||||||
@@ -479,7 +474,8 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null];
|
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||||
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
@@ -579,11 +575,13 @@ export function SharedSchedule() {
|
|||||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether to show stock status indicators on the shared schedule
|
// Whether to show stock status indicators on the shared schedule
|
||||||
const showStock = data?.shareStockStatus !== false;
|
const showStock = data?.shareStockStatus !== false;
|
||||||
|
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||||
|
|
||||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||||
function isDoseIdDone(doseId: string): boolean {
|
function isDoseIdDone(doseId: string): boolean {
|
||||||
@@ -606,7 +604,7 @@ export function SharedSchedule() {
|
|||||||
const missedPastDoseIds = useMemo(() => {
|
const missedPastDoseIds = useMemo(() => {
|
||||||
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||||
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
|
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
|
||||||
}, [pastDays, takenDoses, dismissedDoses, data]);
|
}, [pastDays, isDoseIdDone]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -714,14 +712,20 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="shared-schedule-period">
|
{!showOnlyToday &&
|
||||||
{t("share.period")}:{" "}
|
(() => {
|
||||||
{data.scheduleDays === 30
|
const periodLabel =
|
||||||
|
data.scheduleDays === 30
|
||||||
? t("dashboard.schedules.1month")
|
? t("dashboard.schedules.1month")
|
||||||
: data.scheduleDays === 90
|
: data.scheduleDays === 90
|
||||||
? t("dashboard.schedules.3months")
|
? t("dashboard.schedules.3months")
|
||||||
: t("dashboard.schedules.6months")}
|
: t("dashboard.schedules.6months");
|
||||||
|
return (
|
||||||
|
<p className="shared-schedule-period">
|
||||||
|
{t("share.period")}: {periodLabel}
|
||||||
</p>
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
@@ -730,7 +734,8 @@ export function SharedSchedule() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Past days (when expanded) — rendered above toggle */}
|
{/* Past days (when expanded) — rendered above toggle */}
|
||||||
{showPastDays &&
|
{!showOnlyToday &&
|
||||||
|
showPastDays &&
|
||||||
pastDays.map((day) => {
|
pastDays.map((day) => {
|
||||||
// Get ALL dose IDs for this day (for total count and yellow styling)
|
// Get ALL dose IDs for this day (for total count and yellow styling)
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
@@ -757,14 +762,18 @@ export function SharedSchedule() {
|
|||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
|
|
||||||
|
const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : "";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.dateStr}
|
key={day.dateStr}
|
||||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -817,11 +826,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -837,9 +853,12 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
@@ -859,7 +878,8 @@ export function SharedSchedule() {
|
|||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -875,7 +895,8 @@ export function SharedSchedule() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Past days toggle */}
|
{/* Past days toggle */}
|
||||||
{pastDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
pastDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const missedCount = missedPastDoseIds.length;
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||||
@@ -894,6 +915,9 @@ export function SharedSchedule() {
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||||
<span className="past-days-label">
|
<span className="past-days-label">
|
||||||
@@ -941,6 +965,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -982,11 +1009,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1006,9 +1040,12 @@ export function SharedSchedule() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div
|
<div
|
||||||
@@ -1030,7 +1067,8 @@ export function SharedSchedule() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,7 +1085,8 @@ export function SharedSchedule() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Future days toggle — identical to DashboardPage */}
|
{/* Future days toggle — identical to DashboardPage */}
|
||||||
{futureDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
futureDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
|
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
|
||||||
@@ -1058,6 +1097,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
||||||
onClick={() => setShowFutureDays(!showFutureDays)}
|
onClick={() => setShowFutureDays(!showFutureDays)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
||||||
<span className="future-days-label">
|
<span className="future-days-label">
|
||||||
@@ -1079,7 +1121,8 @@ export function SharedSchedule() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Future days (when expanded) — identical to DashboardPage */}
|
{/* Future days (when expanded) — identical to DashboardPage */}
|
||||||
{showFutureDays &&
|
{!showOnlyToday &&
|
||||||
|
showFutureDays &&
|
||||||
futureDays.map((day) => {
|
futureDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1099,6 +1142,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -1139,11 +1185,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1159,9 +1212,12 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
@@ -1181,7 +1237,8 @@ export function SharedSchedule() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,7 +1272,13 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
{/* Image Lightbox */}
|
{/* Image Lightbox */}
|
||||||
{lightboxImage && (
|
{lightboxImage && (
|
||||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
<div
|
||||||
|
className="lightbox-overlay"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button className="lightbox-close" onClick={closeLightbox}>
|
<button className="lightbox-close" onClick={closeLightbox}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -1224,6 +1287,9 @@ export function SharedSchedule() {
|
|||||||
alt={lightboxImage.name}
|
alt={lightboxImage.name}
|
||||||
className="lightbox-image"
|
className="lightbox-image"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||||
import { getMedTotal, getPackageSize } from "../types";
|
import { getMedTotal, getPackageSize } from "../types";
|
||||||
import { formatNumber } from "../utils";
|
import { formatNumber } from "../utils";
|
||||||
@@ -31,13 +32,27 @@ export function UserFilterModal({
|
|||||||
}: UserFilterModalProps) {
|
}: UserFilterModalProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
useEscapeKey(!!selectedUser, onClose);
|
||||||
|
|
||||||
if (!selectedUser) return null;
|
if (!selectedUser) return null;
|
||||||
|
|
||||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content user-meds-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -75,6 +90,12 @@ export function UserFilterModal({
|
|||||||
onClearUser();
|
onClearUser();
|
||||||
onOpenMedDetail(med);
|
onOpenMedDetail(med);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
onClearUser();
|
||||||
|
onOpenMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
<div className="user-med-info">
|
<div className="user-med-info">
|
||||||
@@ -82,18 +103,19 @@ export function UserFilterModal({
|
|||||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||||
{personIntakes.length > 0 && (
|
{personIntakes.length > 0 && (
|
||||||
<div className="user-med-intakes">
|
<div className="user-med-intakes">
|
||||||
{personIntakes.map((intake, idx) => {
|
{personIntakes.map((intake) => {
|
||||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||||
return (
|
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")}
|
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med.pillWeightMg != null &&
|
{med.pillWeightMg != null &&
|
||||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||||
{t("form.blisters.every")} {intake.every}{" "}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||||
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr}
|
{t("modal.at")} {timeStr}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { ConfirmModal } from "./ConfirmModal";
|
|||||||
export { DateInput } from "./DateInput";
|
export { DateInput } from "./DateInput";
|
||||||
export { DateTimeInput } from "./DateTimeInput";
|
export { DateTimeInput } from "./DateTimeInput";
|
||||||
export { default as ExportModal } from "./ExportModal";
|
export { default as ExportModal } from "./ExportModal";
|
||||||
|
export { FormNumberStepper } from "./FormNumberStepper";
|
||||||
export type { LightboxProps } from "./Lightbox";
|
export type { LightboxProps } from "./Lightbox";
|
||||||
|
|
||||||
export { Lightbox } from "./Lightbox";
|
export { Lightbox } from "./Lightbox";
|
||||||
@@ -17,6 +18,7 @@ export type { MobileEditModalProps } from "./MobileEditModal";
|
|||||||
export { MobileEditModal } from "./MobileEditModal";
|
export { MobileEditModal } from "./MobileEditModal";
|
||||||
export { PasswordInput } from "./PasswordInput";
|
export { PasswordInput } from "./PasswordInput";
|
||||||
export { default as ProfileModal } from "./ProfileModal";
|
export { default as ProfileModal } from "./ProfileModal";
|
||||||
|
export { default as ReportModal } from "./ReportModal";
|
||||||
export type { ShareDialogProps } from "./ShareDialog";
|
export type { ShareDialogProps } from "./ShareDialog";
|
||||||
export { ShareDialog } from "./ShareDialog";
|
export { ShareDialog } from "./ShareDialog";
|
||||||
export { SharedSchedule } from "./SharedSchedule";
|
export { SharedSchedule } from "./SharedSchedule";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type React from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
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 { getSystemLocale } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -72,6 +72,7 @@ export interface AppContextValue {
|
|||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -119,12 +120,15 @@ export interface AppContextValue {
|
|||||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockPartialBlisterPills: number;
|
editStockPartialBlisterPills: number;
|
||||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
editStockLoosePills: number;
|
||||||
|
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockSaving: boolean;
|
editStockSaving: boolean;
|
||||||
|
editStockMedication: Medication | null;
|
||||||
loadRefillHistory: (medId: number) => Promise<void>;
|
loadRefillHistory: (medId: number) => Promise<void>;
|
||||||
submitRefill: (
|
submitRefill: (
|
||||||
medId: number,
|
medId: number,
|
||||||
editingId: number | null,
|
editingId: number | null,
|
||||||
setForm: React.Dispatch<React.SetStateAction<any>>,
|
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||||
loadMeds: () => void,
|
loadMeds: () => void,
|
||||||
usePrescription?: boolean
|
usePrescription?: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
@@ -175,8 +179,20 @@ export interface AppContextValue {
|
|||||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
pendingImportData: unknown;
|
pendingImportData: unknown;
|
||||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||||
importResult: { medications: number; doses: number; shares: number } | null;
|
importResult: {
|
||||||
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null;
|
||||||
|
setImportResult: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null>
|
||||||
|
>;
|
||||||
handleExport: (includeImages?: boolean) => Promise<void>;
|
handleExport: (includeImages?: boolean) => Promise<void>;
|
||||||
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
handleImportConfirm: () => Promise<void>;
|
handleImportConfirm: () => Promise<void>;
|
||||||
@@ -197,7 +213,17 @@ export interface AppContextValue {
|
|||||||
// Context
|
// 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
|
// Helper for user-specific localStorage keys
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
@@ -227,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
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 [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||||
|
const imageLightboxOpenedAtRef = useRef(0);
|
||||||
|
const imageLightboxCloseInFlightRef = useRef(false);
|
||||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||||
|
const scheduleLightboxOpenedAtRef = useRef(0);
|
||||||
|
const scheduleLightboxCloseInFlightRef = useRef(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showImageLightbox) {
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduleLightboxImage) {
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -237,7 +286,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||||
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
|
const [importResult, setImportResult] = useState<{
|
||||||
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Load user-specific scheduleDays when user changes
|
// Load user-specific scheduleDays when user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,7 +403,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Normal/High stock
|
// Normal/High stock
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
},
|
},
|
||||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
||||||
);
|
);
|
||||||
@@ -435,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Modal helpers with browser history support
|
// Modal helpers with browser history support
|
||||||
const openMedDetail = useCallback(
|
const openMedDetail = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
|
if (selectedMedIdRef.current === med.id) return;
|
||||||
|
selectedMedIdRef.current = med.id;
|
||||||
|
medDetailOpenedAtRef.current = Date.now();
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
setSelectedMed(med);
|
setSelectedMed(med);
|
||||||
refill.setRefillHistoryExpanded(false);
|
refill.setRefillHistoryExpanded(false);
|
||||||
refill.loadRefillHistory(med.id);
|
refill.loadRefillHistory(med.id);
|
||||||
@@ -444,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closeMedDetail = useCallback(() => {
|
const closeMedDetail = useCallback(() => {
|
||||||
if (selectedMed) {
|
if (!selectedMed || medDetailCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
|
||||||
|
// 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]);
|
}, [selectedMed]);
|
||||||
|
|
||||||
const openImageLightbox = useCallback(() => {
|
const openImageLightbox = useCallback(() => {
|
||||||
|
if (showImageLightbox) return;
|
||||||
|
imageLightboxOpenedAtRef.current = Date.now();
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
setShowImageLightbox(true);
|
setShowImageLightbox(true);
|
||||||
window.history.pushState({ modal: "imageLightbox" }, "");
|
window.history.pushState({ modal: "imageLightbox" }, "");
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeImageLightbox = useCallback(() => {
|
|
||||||
if (showImageLightbox) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}, [showImageLightbox]);
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
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);
|
setScheduleLightboxImage(imageUrl);
|
||||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||||
}, []);
|
},
|
||||||
|
[scheduleLightboxImage]
|
||||||
|
);
|
||||||
|
|
||||||
const closeScheduleLightbox = useCallback(() => {
|
const closeScheduleLightbox = useCallback(() => {
|
||||||
if (scheduleLightboxImage) {
|
if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
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]);
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
const openUserFilter = useCallback((person: string) => {
|
const openUserFilter = useCallback(
|
||||||
|
(person: string) => {
|
||||||
|
if (selectedUser === person) return;
|
||||||
setSelectedUser(person);
|
setSelectedUser(person);
|
||||||
window.history.pushState({ modal: "userFilter", person }, "");
|
window.history.pushState({ modal: "userFilter", person }, "");
|
||||||
}, []);
|
},
|
||||||
|
[selectedUser]
|
||||||
|
);
|
||||||
|
|
||||||
const closeUserFilter = useCallback(() => {
|
const closeUserFilter = useCallback(() => {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
@@ -505,9 +605,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
const dateStr = new Date().toISOString().split("T")[0];
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||||
|
const userPart = user?.username ? `-${user.username}` : "";
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${t("exportImport.downloadFilename")}-${dateStr}.json`;
|
a.download = `${t("exportImport.downloadFilename")}${userPart}-${dateStr}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@@ -517,7 +619,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
},
|
},
|
||||||
[t]
|
[t, user?.username]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle file selection for import
|
// Handle file selection for import
|
||||||
@@ -581,6 +683,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setImportResult({
|
setImportResult({
|
||||||
medications: data.imported?.medications || 0,
|
medications: data.imported?.medications || 0,
|
||||||
doses: data.imported?.doseHistory || 0,
|
doses: data.imported?.doseHistory || 0,
|
||||||
|
refills: data.imported?.refillHistory || 0,
|
||||||
shares: data.imported?.shareLinks || 0,
|
shares: data.imported?.shareLinks || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -623,6 +726,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
||||||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
|
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
|
||||||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
|
settings.shareStockStatus !== savedSettings.shareStockStatus ||
|
||||||
|
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
|
||||||
|
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
|
||||||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
|
settings.expiryWarningDays !== savedSettings.expiryWarningDays
|
||||||
);
|
);
|
||||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||||
@@ -706,6 +811,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||||
getDoseId: doses.getDoseId,
|
getDoseId: doses.getDoseId,
|
||||||
|
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||||
countTakenDoses: doses.countTakenDoses,
|
countTakenDoses: doses.countTakenDoses,
|
||||||
markDoseTaken: doses.markDoseTaken,
|
markDoseTaken: doses.markDoseTaken,
|
||||||
undoDoseTaken: doses.undoDoseTaken,
|
undoDoseTaken: doses.undoDoseTaken,
|
||||||
@@ -753,7 +859,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
||||||
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
||||||
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
||||||
|
editStockLoosePills: refill.editStockLoosePills,
|
||||||
|
setEditStockLoosePills: refill.setEditStockLoosePills,
|
||||||
editStockSaving: refill.editStockSaving,
|
editStockSaving: refill.editStockSaving,
|
||||||
|
editStockMedication: refill.editStockMedication,
|
||||||
loadRefillHistory: refill.loadRefillHistory,
|
loadRefillHistory: refill.loadRefillHistory,
|
||||||
submitRefill: refill.submitRefill,
|
submitRefill: refill.submitRefill,
|
||||||
submitStockCorrection: refill.submitStockCorrection,
|
submitStockCorrection: refill.submitStockCorrection,
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ export type { UseCollapsedDaysReturn } from "./useCollapsedDays";
|
|||||||
export { useCollapsedDays } from "./useCollapsedDays";
|
export { useCollapsedDays } from "./useCollapsedDays";
|
||||||
export type { UseDosesReturn } from "./useDoses";
|
export type { UseDosesReturn } from "./useDoses";
|
||||||
export { useDoses } from "./useDoses";
|
export { useDoses } from "./useDoses";
|
||||||
|
export { useEscapeKey } from "./useEscapeKey";
|
||||||
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||||
export type { UseMedicationsReturn } from "./useMedications";
|
export type { UseMedicationsReturn } from "./useMedications";
|
||||||
export { useMedications } from "./useMedications";
|
export { useMedications } from "./useMedications";
|
||||||
|
export { useModalHistory } from "./useModalHistory";
|
||||||
export type { UseRefillReturn } from "./useRefill";
|
export type { UseRefillReturn } from "./useRefill";
|
||||||
export { useRefill } from "./useRefill";
|
export { useRefill } from "./useRefill";
|
||||||
|
export { useScrollLock } from "./useScrollLock";
|
||||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||||
export { useSettings } from "./useSettings";
|
export { useSettings } from "./useSettings";
|
||||||
export type { UseShareReturn } from "./useShare";
|
export type { UseShareReturn } from "./useShare";
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
|
|||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
takenDoseTimestamps: Map<string, number>;
|
takenDoseTimestamps: Map<string, number>;
|
||||||
|
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||||
dismissedDoses: Set<string>;
|
dismissedDoses: Set<string>;
|
||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
|
|||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
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 [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const timestamps = new Map<string, number>();
|
const timestamps = new Map<string, number>();
|
||||||
|
const sources = new Map<string, "manual" | "automatic">();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses) {
|
for (const d of data.doses) {
|
||||||
if (d.dismissed) {
|
if (d.dismissed) {
|
||||||
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
timestamps.set(d.doseId, d.takenAt);
|
timestamps.set(d.doseId, d.takenAt);
|
||||||
|
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setTakenDoseTimestamps(timestamps);
|
setTakenDoseTimestamps(timestamps);
|
||||||
|
setTakenDoseSources(sources);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
|
|||||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isDoseTakenAutomatically = useCallback(
|
||||||
|
(doseId: string): boolean => {
|
||||||
|
return takenDoseSources.get(doseId) === "automatic";
|
||||||
|
},
|
||||||
|
[takenDoseSources]
|
||||||
|
);
|
||||||
|
|
||||||
// Count taken doses for a day/item
|
// Count taken doses for a day/item
|
||||||
const countTakenDoses = useCallback(
|
const countTakenDoses = useCallback(
|
||||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||||
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.set(doseId, Date.now());
|
next.set(doseId, Date.now());
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(doseId, "manual");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
mutationInFlightRef.current--;
|
mutationInFlightRef.current--;
|
||||||
// Re-sync with server after mutation completes
|
// Re-sync with server after mutation completes
|
||||||
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
takenDoses,
|
takenDoses,
|
||||||
setTakenDoses,
|
setTakenDoses,
|
||||||
takenDoseTimestamps,
|
takenDoseTimestamps,
|
||||||
|
takenDoseSources,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
countTakenDoses,
|
countTakenDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
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]);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user