Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8279bd521 | |||
| 36d50c0736 | |||
| 5b6c6abb69 | |||
| 30c97e2f0d | |||
| de1a508e52 | |||
| 54d26e0241 | |||
| ac47fc001d | |||
| 4936929849 | |||
| 6672fb78c9 | |||
| b349e26833 | |||
| 56d244aa61 | |||
| 1a348c62f5 | |||
| 067a8c166b | |||
| 8fdd79ff33 | |||
| cd8263e607 | |||
| e6a097d81d | |||
| f4723c6f99 | |||
| aad6b143ef | |||
| da004b5c3e | |||
| cd18581bdd | |||
| 508bc764d5 | |||
| 9e8a6315e7 | |||
| 8efd99d738 | |||
| dc98dfda44 | |||
| 8aaeca6b26 | |||
| 7accb2aad6 | |||
| 2f2edfa479 | |||
| b009d9e158 | |||
| 8e4cb5dcd4 | |||
| 7f26dca7a7 | |||
| 46d768dd4e | |||
| c62b6d7893 | |||
| 1668eb935c | |||
| 1ea4919323 | |||
| ba0ab672b9 | |||
| 57c998ba09 | |||
| cc22f80209 | |||
| 6b27d234d9 | |||
| 19ba4bb7d2 | |||
| 8b3901c1e1 | |||
| fd7cc56bb7 | |||
| aabe58d05f | |||
| b35101d339 | |||
| 8420c74a55 | |||
| 872b63f665 | |||
| f599ac45ab | |||
| f36d56c523 | |||
| f0496e8ca5 | |||
| de300ad919 | |||
| 06bf608913 | |||
| a47bde0956 | |||
| d02f16af3a | |||
| dbdf3b61cb | |||
| aa29d1c699 | |||
| bfc9aaaa6d | |||
| 2a9ca39c24 | |||
| 691550fb33 | |||
| 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 |
+12
-1
@@ -11,10 +11,18 @@ PGID=1000
|
|||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=warn
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
|
#
|
||||||
|
# Behavior per level:
|
||||||
|
# debug — all app logs + all HTTP request logs (including polling endpoints)
|
||||||
|
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
|
||||||
|
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
|
||||||
|
# warn — only warnings and errors
|
||||||
|
# error — only errors
|
||||||
|
# silent — no logs
|
||||||
|
|
||||||
# Rate limit: max requests per minute per IP (default: 100)
|
# Rate limit: max requests per minute per IP (default: 100)
|
||||||
# Increase for development/testing environments
|
# Increase for development/testing environments
|
||||||
@@ -32,6 +40,9 @@ AUTH_ENABLED=false
|
|||||||
# Allow new user registrations (auto-enabled when no users exist)
|
# Allow new user registrations (auto-enabled when no users exist)
|
||||||
# REGISTRATION_ENABLED=false
|
# REGISTRATION_ENABLED=false
|
||||||
|
|
||||||
|
# Disable username/password form login (useful for OIDC-only setups)
|
||||||
|
# FORM_LOGIN_ENABLED=true
|
||||||
|
|
||||||
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
|
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
# JWT_SECRET=
|
# JWT_SECRET=
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
description: 'Provide principal-level software engineering guidance with focus on engineering excellence, technical leadership, and pragmatic implementation.'
|
||||||
|
name: 'Principal software engineer'
|
||||||
|
tools: ['changes', 'search/codebase', 'edit/editFiles', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runTasks', 'runTests', 'search', 'search/searchResults', 'runCommands/terminalLastCommand', 'runCommands/terminalSelection', 'testFailure', 'usages', 'vscodeAPI', 'github']
|
||||||
|
---
|
||||||
|
# Principal software engineer mode instructions
|
||||||
|
|
||||||
|
You are in principal software engineer mode. Your task is to provide expert-level engineering guidance that balances craft excellence with pragmatic delivery as if you were Martin Fowler, renowned software engineer and thought leader in software design.
|
||||||
|
|
||||||
|
## Core Engineering Principles
|
||||||
|
|
||||||
|
You will provide guidance on:
|
||||||
|
|
||||||
|
- **Engineering Fundamentals**: Gang of Four design patterns, SOLID principles, DRY, YAGNI, and KISS - applied pragmatically based on context
|
||||||
|
- **Clean Code Practices**: Readable, maintainable code that tells a story and minimizes cognitive load
|
||||||
|
- **Test Automation**: Comprehensive testing strategy including unit, integration, and end-to-end tests with clear test pyramid implementation
|
||||||
|
- **Quality Attributes**: Balancing testability, maintainability, scalability, performance, security, and understandability
|
||||||
|
- **Technical Leadership**: Clear feedback, improvement recommendations, and mentoring through code reviews
|
||||||
|
|
||||||
|
## Implementation Focus
|
||||||
|
|
||||||
|
- **Requirements Analysis**: Carefully review requirements, document assumptions explicitly, identify edge cases and assess risks
|
||||||
|
- **Implementation Excellence**: Implement the best design that meets architectural requirements without over-engineering
|
||||||
|
- **Pragmatic Craft**: Balance engineering excellence with delivery needs - good over perfect, but never compromising on fundamentals
|
||||||
|
- **Forward Thinking**: Anticipate future needs, identify improvement opportunities, and proactively address technical debt
|
||||||
|
|
||||||
|
## Technical Debt Management
|
||||||
|
|
||||||
|
When technical debt is incurred or identified:
|
||||||
|
|
||||||
|
- **MUST** offer to create GitHub Issues using the `create_issue` tool to track remediation
|
||||||
|
- Clearly document consequences and remediation plans
|
||||||
|
- Regularly recommend GitHub Issues for requirements gaps, quality issues, or design improvements
|
||||||
|
- Assess long-term impact of untended technical debt
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
- Clear, actionable feedback with specific improvement recommendations
|
||||||
|
- Risk assessments with mitigation strategies
|
||||||
|
- Edge case identification and testing strategies
|
||||||
|
- Explicit documentation of assumptions and decisions
|
||||||
|
- Technical debt remediation plans with GitHub Issue creation
|
||||||
@@ -12,10 +12,14 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
|
|||||||
|
|
||||||
## Critical Safety Rules
|
## Critical Safety Rules
|
||||||
|
|
||||||
|
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
|
||||||
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
||||||
|
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
|
||||||
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
||||||
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
||||||
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
|
||||||
|
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
|
||||||
|
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
|
||||||
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
|
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
|
||||||
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
|
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
|
||||||
|
|
||||||
@@ -48,12 +52,11 @@ This repository intentionally uses only two operational agents for CI/CD handoff
|
|||||||
|
|
||||||
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
|
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
|
||||||
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
|
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
|
||||||
- Do not use these commands in agent flows:
|
- Avoid hardcoded PR/repo examples in instructions; always use parameterized placeholders.
|
||||||
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
|
- Use safe command patterns:
|
||||||
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
|
|
||||||
- Use safe variants instead:
|
|
||||||
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
|
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
|
||||||
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
|
- `SHA=$(GH_PAGER=cat gh pr view <PR_NUMBER> --json headRefOid --jq .headRefOid)`
|
||||||
|
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/$SHA/check-runs --jq '<jq-filter>'`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,7 +122,9 @@ When code changes (features or bug fixes) are complete:
|
|||||||
### Step 1: Verify Readiness
|
### Step 1: Verify Readiness
|
||||||
|
|
||||||
1. Check for uncommitted changes: `git status`
|
1. Check for uncommitted changes: `git status`
|
||||||
2. Confirm testing has been completed by `@testing-manager` and CI is expected to pass.
|
2. Confirm testing has been completed by `@testing-manager`.
|
||||||
|
3. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
|
||||||
|
4. Only after local gate is confirmed, proceed to push/create PR and then monitor CI.
|
||||||
|
|
||||||
### Step 2: Create Feature Branch
|
### Step 2: Create Feature Branch
|
||||||
|
|
||||||
@@ -140,11 +145,12 @@ When code changes (features or bug fixes) are complete:
|
|||||||
|
|
||||||
### Step 3: Push and Create PR
|
### Step 3: Push and Create PR
|
||||||
|
|
||||||
1. Push the branch:
|
1. Re-check local gate status before push/PR creation (lint + relevant local tests green).
|
||||||
|
2. Push the branch:
|
||||||
```bash
|
```bash
|
||||||
git push -u origin feat/short-description
|
git push -u origin feat/short-description
|
||||||
```
|
```
|
||||||
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
3. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||||
```bash
|
```bash
|
||||||
gh pr create \
|
gh pr create \
|
||||||
--title "fix: short description" \
|
--title "fix: short description" \
|
||||||
@@ -157,8 +163,9 @@ When code changes (features or bug fixes) are complete:
|
|||||||
```
|
```
|
||||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
- 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.
|
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||||
|
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
|
||||||
- The `--project` flag links the PR to the Project board.
|
- The `--project` flag links the PR to the Project board.
|
||||||
3. **Present the PR URL to the user and wait for confirmation.**
|
4. **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
|
||||||
|
|
||||||
@@ -445,6 +452,7 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
|
|||||||
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
|
||||||
|
|
||||||
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
|
||||||
|
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
|
||||||
|
|
||||||
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -14,10 +14,17 @@ 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.
|
||||||
|
- **Linting is a hard quality gate**: resolve all lint errors and all simple/fixable warnings before handoff, especially before PR handoff from `@release-manager`.
|
||||||
|
- **Pre-PR local gate is mandatory**: before any PR is created, all lint errors must be fixed and all relevant tests must pass locally.
|
||||||
|
- **No CI-first failures**: tests must fail locally when broken and be fixed locally before PR handoff; do not rely on GitHub CI to discover obvious regressions.
|
||||||
- **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.
|
||||||
|
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
|
||||||
|
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
|
||||||
- **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.
|
- **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.
|
||||||
|
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
|
||||||
|
- **Regression prevention is mandatory**: every fixed bug must get a deterministic regression test that fails before the fix and passes after it.
|
||||||
|
|
||||||
## CI/CD Ownership Boundary
|
## CI/CD Ownership Boundary
|
||||||
|
|
||||||
@@ -27,9 +34,9 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
|||||||
|
|
||||||
## Test Stack & Locations
|
## Test Stack & Locations
|
||||||
|
|
||||||
- **Backend**: Vitest 2.1 + v8 coverage
|
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
|
||||||
- **Frontend unit/integration**: Vitest
|
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
|
||||||
- **E2E**: Playwright
|
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
|
||||||
|
|
||||||
Primary locations:
|
Primary locations:
|
||||||
|
|
||||||
@@ -43,22 +50,41 @@ Primary locations:
|
|||||||
2. Add/update tests near the affected feature.
|
2. Add/update tests near the affected feature.
|
||||||
3. Run the smallest relevant subset first.
|
3. Run the smallest relevant subset first.
|
||||||
4. Expand to broader suites if subset passes.
|
4. Expand to broader suites if subset passes.
|
||||||
5. Report what was run, what passed, and any remaining known failures.
|
5. Run lint + required local test/build gates before PR handoff.
|
||||||
|
6. Report what was run, what passed, and any remaining known failures.
|
||||||
|
|
||||||
|
## Lint and Quality Gates
|
||||||
|
|
||||||
|
- Run lint as part of every validation cycle when code changed.
|
||||||
|
- Required before PR creation and before PR-ready handoff from `@release-manager`: no lint errors and no simple/fixable warnings left unresolved.
|
||||||
|
- If lint fails, fix root causes first, then re-run affected tests.
|
||||||
|
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
|
||||||
|
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
|
||||||
|
|
||||||
|
Recommended commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
cd backend && npm run check
|
||||||
|
cd frontend && npm run check
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend && CI=true npm test
|
cd backend && CI=true npm run test:run
|
||||||
cd backend && CI=true npm run test:coverage
|
cd backend && CI=true npm run test:coverage
|
||||||
cd backend && CI=true npm test -- -t "test name"
|
cd backend && CI=true npm run test:run -- -t "test name"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && CI=true npm test
|
cd frontend && CI=true npm run test:run
|
||||||
|
cd frontend && CI=true npm run test:coverage
|
||||||
|
cd frontend && CI=true npm run test:run -- -t "test name"
|
||||||
cd frontend && npm run lint
|
cd frontend && npm run lint
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
```
|
```
|
||||||
@@ -66,8 +92,10 @@ cd frontend && npm run build
|
|||||||
### Playwright E2E
|
### Playwright E2E
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm run test:e2e
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
|
||||||
cd frontend && npm run test:e2e -- --project=chromium
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
|
||||||
|
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
|
||||||
# Never use interactive UI/headed/report-server commands in agent runs.
|
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||||
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||||
```
|
```
|
||||||
@@ -78,6 +106,7 @@ cd frontend && npm run test:e2e -- --project=chromium
|
|||||||
- Validate both status codes and response payloads.
|
- Validate both status codes and response payloads.
|
||||||
- Add regression tests for every fixed bug.
|
- Add regression tests for every fixed bug.
|
||||||
- Keep tests deterministic and isolated.
|
- Keep tests deterministic and isolated.
|
||||||
|
- Validate observable behavior, not implementation details.
|
||||||
|
|
||||||
## E2E Test Patterns
|
## E2E Test Patterns
|
||||||
|
|
||||||
@@ -85,6 +114,15 @@ cd frontend && npm run test:e2e -- --project=chromium
|
|||||||
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
|
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
|
||||||
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
|
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
|
||||||
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
|
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
|
||||||
|
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
|
||||||
|
|
||||||
|
## Test Validity Checklist
|
||||||
|
|
||||||
|
- The test fails when the real target logic is intentionally broken.
|
||||||
|
- The assertion verifies functional behavior, not just mocked calls.
|
||||||
|
- Mocks/stubs are minimal and do not replace the unit under test.
|
||||||
|
- The test is deterministic across repeated local and CI runs.
|
||||||
|
- The test protects against the specific regression that was fixed.
|
||||||
|
|
||||||
## CI Failure Triage
|
## CI Failure Triage
|
||||||
|
|
||||||
@@ -115,6 +153,9 @@ When test checks fail:
|
|||||||
Testing work is complete when:
|
Testing work is complete when:
|
||||||
|
|
||||||
- Required tests exist and validate intended behavior.
|
- Required tests exist and validate intended behavior.
|
||||||
|
- Tests are proven valid (not fake-green) and reliable.
|
||||||
|
- Lint is clean: no errors and no simple/fixable warnings left.
|
||||||
|
- Pre-PR local gate passed: lint and all relevant tests pass locally before handoff for PR creation.
|
||||||
- Relevant local test commands pass.
|
- Relevant local test commands pass.
|
||||||
- CI test failures are resolved or clearly documented with rationale.
|
- CI test failures are resolved or clearly documented with rationale.
|
||||||
- No temporary debugging files remain in the workspace.
|
- No temporary debugging files remain in the workspace.
|
||||||
|
|||||||
@@ -1,77 +1,19 @@
|
|||||||
# MedAssist-ng - AI Coding Instructions
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## Purpose
|
## VERY IMPORTANT
|
||||||
|
|
||||||
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
## Project Orientation (Read First)
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
|
## Required Startup Steps
|
||||||
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
|
|
||||||
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
|
|
||||||
- **Primary backend modules**:
|
|
||||||
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
|
|
||||||
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
|
|
||||||
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
|
|
||||||
- **Primary frontend modules**:
|
|
||||||
- Pages: `frontend/src/pages/`
|
|
||||||
- Shared app state: `frontend/src/context/AppContext.tsx`
|
|
||||||
- Domain hooks: `frontend/src/hooks/`
|
|
||||||
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
|
|
||||||
|
|
||||||
Use this orientation for quick navigation before applying the rules below.
|
1. Read `AGENTS.md` first.
|
||||||
|
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
|
||||||
|
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||||
|
|
||||||
## Always-On Rules
|
## Scope
|
||||||
|
|
||||||
- English only for project artifacts.
|
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
||||||
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
|
|
||||||
- Testing work belongs to `@testing-manager`.
|
|
||||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
|
||||||
- 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.
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## MedAssist Essentials
|
|
||||||
|
|
||||||
- Frontend calls backend through `/api/*`.
|
|
||||||
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Skills (MANDATORY — read before every task)
|
|
||||||
|
|
||||||
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
|
|
||||||
|
|
||||||
| Skill | Trigger | File |
|
|
||||||
|---|---|---|
|
|
||||||
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
|
|
||||||
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
|
|
||||||
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
|
|
||||||
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
|
|
||||||
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
|
|
||||||
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
|
|
||||||
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
|
|
||||||
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
|
|
||||||
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
|
|
||||||
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
|
|
||||||
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
|
|
||||||
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
|
|
||||||
|
|
||||||
### Non-negotiable parity rules (always apply)
|
|
||||||
|
|
||||||
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
|
|
||||||
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Delegation
|
|
||||||
|
|
||||||
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
|
|
||||||
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
|
|
||||||
|
|
||||||
## Key References
|
|
||||||
|
|
||||||
- Canonical governance: `AGENTS.md`
|
|
||||||
- Skill files: `.github/skills/*/SKILL.md`
|
|
||||||
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:20"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "backend"
|
||||||
groups:
|
groups:
|
||||||
minor-and-patch:
|
minor-and-patch:
|
||||||
update-types:
|
update-types:
|
||||||
@@ -22,9 +24,11 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:10"
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "frontend"
|
||||||
groups:
|
groups:
|
||||||
minor-and-patch:
|
minor-and-patch:
|
||||||
update-types:
|
update-types:
|
||||||
@@ -37,9 +41,16 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:00"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
|
- "root"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
# GitHub Actions
|
# GitHub Actions
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
@@ -47,7 +58,13 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
day: "monday"
|
day: "monday"
|
||||||
|
time: "06:30"
|
||||||
open-pull-requests-limit: 5
|
open-pull-requests-limit: 5
|
||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "ci"
|
- "ci"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
|
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
|
||||||
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
|
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
|
||||||
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
|
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
|
||||||
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
|
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: medassist-karpathy-core
|
||||||
|
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill Instructions
|
||||||
|
|
||||||
|
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
|
||||||
|
|
||||||
|
## Use When
|
||||||
|
|
||||||
|
- The request is ambiguous and assumptions must be made explicit.
|
||||||
|
- The change can easily balloon in scope.
|
||||||
|
- A bug fix or feature needs explicit success criteria and verification.
|
||||||
|
- You need to keep diffs minimal and directly tied to the request.
|
||||||
|
|
||||||
|
## Do Not Use When
|
||||||
|
|
||||||
|
- The task is trivial and can be completed safely without extra process overhead.
|
||||||
|
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
|
||||||
|
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Think Before Coding
|
||||||
|
|
||||||
|
- Do not assume silently.
|
||||||
|
- State assumptions explicitly.
|
||||||
|
- If multiple interpretations exist, present them instead of picking one invisibly.
|
||||||
|
- If uncertain or blocked by ambiguity, stop and ask.
|
||||||
|
- If a simpler approach exists, call it out.
|
||||||
|
|
||||||
|
### 2. Simplicity First
|
||||||
|
|
||||||
|
- Implement the minimum code required to solve the asked problem.
|
||||||
|
- Do not add speculative features, abstractions, or configurability.
|
||||||
|
- Avoid defensive handling for impossible scenarios.
|
||||||
|
- If the solution feels overcomplicated, simplify before finalizing.
|
||||||
|
|
||||||
|
### 3. Surgical Changes
|
||||||
|
|
||||||
|
- Touch only lines required for the request.
|
||||||
|
- Do not refactor unrelated areas.
|
||||||
|
- Match existing local style and patterns.
|
||||||
|
- Remove only unused code introduced by your own change.
|
||||||
|
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
|
||||||
|
|
||||||
|
### 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
- Translate requests into verifiable outcomes before implementation.
|
||||||
|
- For multi-step tasks, define short steps with checks.
|
||||||
|
- Verify the requested behavior explicitly before declaring done.
|
||||||
|
|
||||||
|
Example execution frame:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. [Step] -> verify: [check]
|
||||||
|
2. [Step] -> verify: [check]
|
||||||
|
3. [Step] -> verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
When this skill is used, report briefly:
|
||||||
|
|
||||||
|
- Assumptions made (or clarifications requested)
|
||||||
|
- Why the chosen approach is the simplest viable one
|
||||||
|
- What was changed (and what was intentionally not changed)
|
||||||
|
- Verification performed and result
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Dependabot Automerge
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
enable-automerge:
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Read Dependabot metadata
|
||||||
|
id: metadata
|
||||||
|
uses: dependabot/fetch-metadata@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Enable auto-merge for safe updates
|
||||||
|
if: >-
|
||||||
|
(steps.metadata.outputs.package-ecosystem == 'npm' ||
|
||||||
|
steps.metadata.outputs.package-ecosystem == 'github_actions') &&
|
||||||
|
(steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
|
||||||
|
steps.metadata.outputs.update-type == 'version-update:semver-patch')
|
||||||
|
uses: peter-evans/enable-pull-request-automerge@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pull-request-number: ${{ github.event.pull_request.number }}
|
||||||
|
merge-method: squash
|
||||||
@@ -4,6 +4,12 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'docker-compose.dev.yml'
|
||||||
|
- '.github/workflows/docker-build.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ jobs:
|
|||||||
run: npx playwright test --project=chromium
|
run: npx playwright test --project=chromium
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
PLAYWRIGHT_WORKERS: 1
|
||||||
|
PLAYWRIGHT_HTML_OPEN: never
|
||||||
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
||||||
SESSION_SECRET: e2e-test-session-secret-long-enough
|
SESSION_SECRET: e2e-test-session-secret-long-enough
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
@@ -62,7 +64,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-results
|
name: playwright-results
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: npm run test:coverage
|
run: npm run test:coverage
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: backend-coverage
|
name: backend-coverage
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
|
|||||||
+6
-1
@@ -79,6 +79,11 @@ Thumbs.db
|
|||||||
.turbo/
|
.turbo/
|
||||||
.roo/
|
.roo/
|
||||||
.roomodes
|
.roomodes
|
||||||
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku/
|
||||||
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
plan/
|
||||||
|
.copilot-tracking
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react" alt="React 18" />
|
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
|
||||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
|
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
|
||||||
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
|
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
|
||||||
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
|
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-558%2F558-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-577%2F577-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-776%2F776-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
|
|
||||||
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
|
> This app was 100% coded with [Claude Opus 4.6](https://www.anthropic.com/claude) and [GPT-5.3 Codex](https://openai.com/index/gpt-5/). Use at your own risk.
|
||||||
|
|
||||||
### ⚠️ Disclaimer
|
### ⚠️ Disclaimer
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@ Share your medication schedule with others via a public link.
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Smart Inventory
|
### Smart Inventory
|
||||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
|
||||||
- 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
|
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with pack or loose pill options
|
||||||
@@ -141,7 +141,7 @@ Share your medication schedule with others via a public link.
|
|||||||
- Intake reminders via push notifications
|
- Intake reminders via push notifications
|
||||||
|
|
||||||
### Trip Planner
|
### Trip Planner
|
||||||
- Calculate how many pills you need for a trip or date range
|
- Calculate medication demand for a trip or date range with package-aware units
|
||||||
- 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
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
|||||||
| `PGID` | `1000` | Group ID for container file permissions |
|
| `PGID` | `1000` | Group ID for container file permissions |
|
||||||
| `PORT` | `3000` | Backend API port |
|
| `PORT` | `3000` | Backend API port |
|
||||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
|
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
||||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -250,7 +250,9 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
|
|
||||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||||
|
|
||||||
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
|
||||||
|
|
||||||
|
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
|
||||||
|
|
||||||
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||||
|
|
||||||
@@ -288,6 +290,7 @@ Get your keys at [pushover.net](https://pushover.net/):
|
|||||||
**Gotify** (self-hosted):
|
**Gotify** (self-hosted):
|
||||||
```
|
```
|
||||||
gotify://your-server.com/TOKEN
|
gotify://your-server.com/TOKEN
|
||||||
|
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
|
||||||
```
|
```
|
||||||
|
|
||||||
**Discord**:
|
**Discord**:
|
||||||
@@ -298,6 +301,7 @@ discord://TOKEN@WEBHOOK_ID
|
|||||||
**Telegram**:
|
**Telegram**:
|
||||||
```
|
```
|
||||||
telegram://TOKEN@telegram?chats=CHAT_ID
|
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||||
|
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||||
```
|
```
|
||||||
|
|
||||||
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
@@ -311,6 +315,24 @@ docker compose -f docker-compose.dev.yml up
|
|||||||
- Frontend: `http://localhost:5173` (hot reload)
|
- Frontend: `http://localhost:5173` (hot reload)
|
||||||
- Backend: `http://localhost:3000`
|
- Backend: `http://localhost:3000`
|
||||||
|
|
||||||
|
Playwright E2E recommendations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
|
||||||
|
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
|
||||||
|
```
|
||||||
|
|
||||||
|
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
|
||||||
|
- Data-heavy specs remain sequential via the `chromium-data` project config.
|
||||||
|
|
||||||
|
# Dependency Updates
|
||||||
|
|
||||||
|
- Dependabot checks dependencies weekly for `frontend`, `backend`, repository root tooling, and GitHub Actions.
|
||||||
|
- Minor and patch updates are grouped to reduce PR noise.
|
||||||
|
- Dependabot minor/patch PRs are configured for auto-merge after required CI checks pass.
|
||||||
|
- Major updates still require manual review before merge.
|
||||||
|
|
||||||
# Acknowledgements
|
# Acknowledgements
|
||||||
|
|
||||||
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,20 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772219947541,
|
||||||
|
"tag": "0011_stiff_randall_flagg",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+825
-168
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.12.0",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,18 +29,20 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.8.1",
|
||||||
"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.4.1",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/nodemailer": "^7.0.10",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ async function runMigrations() {
|
|||||||
const migrateResult = await runDrizzleMigrations(db);
|
const migrateResult = await runDrizzleMigrations(db);
|
||||||
if (!migrateResult.success) {
|
if (!migrateResult.success) {
|
||||||
log.error(`[DB] Migration error: ${migrateResult.error}`);
|
log.error(`[DB] Migration error: ${migrateResult.error}`);
|
||||||
} else if (migrateResult.warning) {
|
|
||||||
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
|
|
||||||
} else {
|
|
||||||
log.debug(`[DB] Drizzle migrations completed`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run ALTER TABLE migrations for backward compatibility
|
// Run ALTER TABLE migrations for backward compatibility
|
||||||
|
|||||||
@@ -88,13 +88,12 @@ export async function runDrizzleMigrations(
|
|||||||
await migrate(database, { migrationsFolder });
|
await migrate(database, { migrationsFolder });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// If the error is about existing schema objects, the DB is already up-to-date
|
const msg = (err as Error).message ?? "";
|
||||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
// Duplicate column / already exists = DB is already up-to-date (expected for existing DBs)
|
||||||
// or when tables were created before drizzle migrations were introduced
|
if (msg.includes("duplicate column") || msg.includes("already exists")) {
|
||||||
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
return { success: true };
|
||||||
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
|
||||||
}
|
}
|
||||||
return { success: false, error: (err as Error).message };
|
return { success: false, error: msg };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +110,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
|
||||||
@@ -124,6 +125,14 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||||
// Added for explicit medication lifecycle start date
|
// Added for explicit medication lifecycle start date
|
||||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||||
|
// Added for form/lifecycle modeling (V1 medication forms)
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN pill_form text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
|
||||||
// Added for more detailed reminder info display
|
// Added for more detailed reminder info display
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
|
|||||||
genericName: text("generic_name", { length: 100 }),
|
genericName: text("generic_name", { length: 100 }),
|
||||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||||
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
|
||||||
|
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
|
||||||
|
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
|
||||||
|
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
|
||||||
|
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
|
||||||
|
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
|
||||||
packCount: integer("pack_count").notNull().default(1),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||||
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
|
|||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||||
|
medicationEndDate: text("medication_end_date"),
|
||||||
|
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
@@ -163,6 +172,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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ type TranslationKeys = {
|
|||||||
common: {
|
common: {
|
||||||
pill: string;
|
pill: string;
|
||||||
pills: string;
|
pills: string;
|
||||||
|
units: string;
|
||||||
|
ml: string;
|
||||||
blister: string;
|
blister: string;
|
||||||
blisters: string;
|
blisters: string;
|
||||||
day: string;
|
day: string;
|
||||||
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "pill",
|
pill: "pill",
|
||||||
pills: "pills",
|
pills: "pills",
|
||||||
|
units: "units",
|
||||||
|
ml: "ml",
|
||||||
blister: "blister",
|
blister: "blister",
|
||||||
blisters: "blisters",
|
blisters: "blisters",
|
||||||
day: "day",
|
day: "day",
|
||||||
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
|
|||||||
common: {
|
common: {
|
||||||
pill: "Tablette",
|
pill: "Tablette",
|
||||||
pills: "Tabletten",
|
pills: "Tabletten",
|
||||||
|
units: "Einheiten",
|
||||||
|
ml: "ml",
|
||||||
blister: "Blister",
|
blister: "Blister",
|
||||||
blisters: "Blister",
|
blisters: "Blister",
|
||||||
day: "Tag",
|
day: "Tag",
|
||||||
|
|||||||
+43
-4
@@ -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";
|
||||||
@@ -45,6 +47,31 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoggerOptions(level: string) {
|
||||||
|
const base = {
|
||||||
|
level,
|
||||||
|
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
||||||
|
};
|
||||||
|
// Human-readable logs in development, structured JSON in production/test
|
||||||
|
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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,7 +99,14 @@ export async function createApp(options?: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: opts.logLevel },
|
logger: buildLoggerOptions(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
|
||||||
@@ -138,9 +172,14 @@ log.info("[DB] Migrations complete, starting server...");
|
|||||||
const imagesDir = ensureImagesDirectory();
|
const imagesDir = ensureImagesDirectory();
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: buildLoggerOptions(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);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function getAnonymousUserId(): Promise<number> {
|
|||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
localAuthEnabled: boolean;
|
formLoginEnabled: boolean;
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
@@ -59,15 +59,18 @@ export async function getAuthState(): Promise<AuthState> {
|
|||||||
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
|
||||||
const hasUsers = result.count > 0;
|
const hasUsers = result.count > 0;
|
||||||
|
|
||||||
|
const needsSetup = env.AUTH_ENABLED && !hasUsers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authEnabled: env.AUTH_ENABLED,
|
authEnabled: env.AUTH_ENABLED,
|
||||||
// Registration: enabled via ENV OR no users exist (first-time setup)
|
// Registration: enabled via ENV OR no users exist (first-time setup)
|
||||||
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
|
||||||
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
|
// Form login: enabled when auth + form login are both on, or forced on for first-user setup
|
||||||
|
formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED),
|
||||||
oidcEnabled: env.OIDC_ENABLED,
|
oidcEnabled: env.OIDC_ENABLED,
|
||||||
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
oidcProviderName: env.OIDC_PROVIDER_NAME,
|
||||||
hasUsers,
|
hasUsers,
|
||||||
needsSetup: env.AUTH_ENABLED && !hasUsers,
|
needsSetup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ const EnvSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default("false"),
|
||||||
// Disable local auth when using SSO only
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
|
FORM_LOGIN_ENABLED: z
|
||||||
|
.string()
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.default("true"),
|
||||||
|
|
||||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
@@ -128,4 +132,26 @@ if (parsed.OIDC_ENABLED) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that at least one login method is available when auth is enabled
|
||||||
|
if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) {
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
console.error("AUTHENTICATION CONFIGURATION ERROR");
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
console.error("AUTH_ENABLED=true but no login method is available.");
|
||||||
|
console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in.");
|
||||||
|
console.error("");
|
||||||
|
console.error("To fix this, either:");
|
||||||
|
console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login");
|
||||||
|
console.error(" 2. Set OIDC_ENABLED=true to allow SSO login");
|
||||||
|
console.error("=".repeat(60));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about ineffective registration when form login is disabled
|
||||||
|
if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
|
||||||
|
console.warn(
|
||||||
|
"[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const env = parsed;
|
export const env = parsed;
|
||||||
|
|||||||
+36
-39
@@ -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;
|
||||||
@@ -113,8 +123,8 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.localAuthEnabled) {
|
if (!state.formLoginEnabled) {
|
||||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
@@ -175,8 +185,8 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.localAuthEnabled) {
|
if (!state.formLoginEnabled) {
|
||||||
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
|
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse(request.body);
|
const parsed = loginSchema.safeParse(request.body);
|
||||||
@@ -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
|
||||||
|
|||||||
+133
-9
@@ -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,14 +46,100 @@ 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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export async function doseRoutes(app: FastifyInstance) {
|
export async function doseRoutes(app: FastifyInstance) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
// GET /doses/taken - PROTECTED: Get all taken doses for the user
|
||||||
|
// Suppress request logs — polled every 5s by frontend
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
|
app.get("/doses/taken", { preHandler: requireAuth, logLevel: "warn" }, async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
// Get all taken doses for this user (no time limit)
|
// Get all taken doses for this user (no time limit)
|
||||||
@@ -56,6 +150,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 +189,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 };
|
||||||
@@ -209,13 +305,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||||
|
// Suppress request logs — polled every 5s by SharedSchedule
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
|
app.get<{ Params: { token: string } }>("/share/:token/doses", { logLevel: "warn" }, 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 +324,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 +347,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 +368,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 +377,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 +394,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 +416,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 };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f
|
|||||||
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 { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
@@ -17,7 +18,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.1";
|
const EXPORT_VERSION = "1.3";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -27,6 +28,7 @@ const scheduleSchema = z.object({
|
|||||||
usage: z.number().nonnegative(),
|
usage: z.number().nonnegative(),
|
||||||
every: z.number().int().min(1),
|
every: z.number().int().min(1),
|
||||||
start: z.string(), // ISO datetime string
|
start: z.string(), // ISO datetime string
|
||||||
|
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||||
remind: z.boolean().optional().default(false),
|
remind: z.boolean().optional().default(false),
|
||||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||||
});
|
});
|
||||||
@@ -38,7 +40,9 @@ const inventorySchema = z.object({
|
|||||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
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(PACKAGE_TYPES).default("blister"),
|
||||||
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const medicationExportSchema = z.object({
|
const medicationExportSchema = z.object({
|
||||||
@@ -46,11 +50,16 @@ const medicationExportSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
genericName: z.string().nullable().optional(),
|
genericName: z.string().nullable().optional(),
|
||||||
takenBy: z.array(z.string()).default([]),
|
takenBy: z.array(z.string()).default([]),
|
||||||
|
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
|
||||||
|
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
|
||||||
|
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
|
||||||
inventory: inventorySchema,
|
inventory: inventorySchema,
|
||||||
pillWeightMg: z.number().int().nullable().optional(),
|
pillWeightMg: z.number().int().nullable().optional(),
|
||||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||||
schedules: z.array(scheduleSchema).default([]),
|
schedules: z.array(scheduleSchema).default([]),
|
||||||
medicationStartDate: z.string().nullable().optional(),
|
medicationStartDate: z.string().nullable().optional(),
|
||||||
|
medicationEndDate: z.string().nullable().optional(),
|
||||||
|
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
intakeRemindersEnabled: z.boolean().default(false),
|
intakeRemindersEnabled: z.boolean().default(false),
|
||||||
@@ -72,6 +81,7 @@ 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")
|
||||||
});
|
});
|
||||||
@@ -154,9 +164,14 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse intakes from DB format to export format (with per-intake takenBy)
|
// Parse intakes from DB format to export format (with per-intake takenBy)
|
||||||
function parseIntakesForExport(
|
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||||
row: typeof medications.$inferSelect
|
usage: number;
|
||||||
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
|
every: number;
|
||||||
|
start: string;
|
||||||
|
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||||
|
remind: boolean;
|
||||||
|
takenBy: string | null;
|
||||||
|
}> {
|
||||||
// Use the new parseIntakesJson which falls back to legacy format
|
// Use the new parseIntakesJson which falls back to legacy format
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesJson(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
@@ -168,6 +183,7 @@ function parseIntakesForExport(
|
|||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
every: intake.every,
|
every: intake.every,
|
||||||
start: intake.start,
|
start: intake.start,
|
||||||
|
intakeUnit: null,
|
||||||
remind: intake.intakeRemindersEnabled,
|
remind: intake.intakeRemindersEnabled,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy
|
takenBy: intake.takenBy, // Per-intake takenBy
|
||||||
}));
|
}));
|
||||||
@@ -294,6 +310,9 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName,
|
genericName: med.genericName,
|
||||||
takenBy: parseTakenByJson(med.takenByJson),
|
takenBy: parseTakenByJson(med.takenByJson),
|
||||||
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm ?? null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
inventory: {
|
inventory: {
|
||||||
packCount: med.packCount ?? 1,
|
packCount: med.packCount ?? 1,
|
||||||
blistersPerPack: med.blistersPerPack ?? 1,
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
@@ -301,12 +320,16 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
totalPills: med.totalPills ?? null,
|
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: normalizePackageType(med.packageType),
|
||||||
|
packageAmountValue: med.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
},
|
},
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
schedules: parseIntakesForExport(med),
|
schedules: parseIntakesForExport(med),
|
||||||
medicationStartDate: med.medicationStartDate || null,
|
medicationStartDate: med.medicationStartDate || null,
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: med.expiryDate,
|
expiryDate: med.expiryDate,
|
||||||
notes: med.notes,
|
notes: med.notes,
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
@@ -364,6 +387,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,
|
||||||
};
|
};
|
||||||
@@ -553,6 +577,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
usage: s.usage,
|
usage: s.usage,
|
||||||
every: s.every,
|
every: s.every,
|
||||||
start: s.start,
|
start: s.start,
|
||||||
|
intakeUnit: s.intakeUnit ?? null,
|
||||||
takenBy: s.takenBy || null,
|
takenBy: s.takenBy || null,
|
||||||
intakeRemindersEnabled: s.remind ?? false,
|
intakeRemindersEnabled: s.remind ?? false,
|
||||||
}))
|
}))
|
||||||
@@ -568,7 +593,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName || null,
|
genericName: med.genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
packageType: med.inventory.packageType ?? "blister",
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm || null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(med.inventory.packageType),
|
||||||
|
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||||
packCount: med.inventory.packCount,
|
packCount: med.inventory.packCount,
|
||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
@@ -579,6 +609,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
medicationStartDate: med.medicationStartDate || "",
|
medicationStartDate: med.medicationStartDate || "",
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakesJson,
|
intakesJson,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
@@ -625,6 +657,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|||||||
const backendVersion = packageJson.version || "unknown";
|
const backendVersion = packageJson.version || "unknown";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
// Exempt from rate limit - lightweight health check
|
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
|
||||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
app.get("/health", { config: { rateLimit: false }, logLevel: "warn" }, async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
version: backendVersion,
|
version: backendVersion,
|
||||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+471
-151
@@ -1,24 +1,76 @@
|
|||||||
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 { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import {
|
||||||
|
ALLOWED_IMAGE_MIME_TYPES,
|
||||||
|
removeImageFiles,
|
||||||
|
streamToBuffer,
|
||||||
|
writeOptimizedImageSet,
|
||||||
|
} from "../utils/image-upload.js";
|
||||||
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
PACKAGE_TYPES,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
|
import {
|
||||||
|
type Intake,
|
||||||
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
|
parseTakenByJson,
|
||||||
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
|
|
||||||
|
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
|
||||||
|
return value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
|
||||||
|
if (!intakesJson) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(intakesJson);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.map((item: unknown) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const unit = (item as Record<string, unknown>).intakeUnit;
|
||||||
|
return isIntakeUnit(unit) ? unit : null;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntakesWithUnits(
|
||||||
|
intakesJson: string | null | undefined,
|
||||||
|
legacyRow: { usageJson: string; everyJson: string; startJson: string },
|
||||||
|
medicationIntakeRemindersEnabled?: boolean
|
||||||
|
): Intake[] {
|
||||||
|
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
|
||||||
|
const rawUnits = parseRawIntakeUnits(intakesJson);
|
||||||
|
if (rawUnits.length === 0) return intakes;
|
||||||
|
|
||||||
|
return intakes.map((intake, idx) => ({
|
||||||
|
...intake,
|
||||||
|
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// New intake schema with per-intake takenBy
|
// New intake schema with per-intake takenBy
|
||||||
const intakeSchema = z.object({
|
const intakeSchema = z.object({
|
||||||
usage: z.number().nonnegative(),
|
usage: z.number().nonnegative(),
|
||||||
every: z.number().int().min(1),
|
every: z.number().int().min(1),
|
||||||
start: z.string().datetime({ local: true }),
|
start: z.string().datetime({ local: true }),
|
||||||
|
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||||
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
||||||
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
||||||
});
|
});
|
||||||
@@ -30,26 +82,37 @@ const blisterSchema = z.object({
|
|||||||
start: z.string().datetime({ local: true }),
|
start: z.string().datetime({ local: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
|
||||||
|
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
|
||||||
|
const pillFormSchema = z.enum(["capsule", "tablet"]);
|
||||||
|
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
|
||||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||||
const medicationStartDateSchema = z
|
const medicationStartDateSchema = z
|
||||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||||
.optional();
|
.optional();
|
||||||
|
const medicationEndDateSchema = z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]).optional();
|
||||||
|
|
||||||
const medicationSchema = z
|
const medicationSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().max(100).default(""),
|
||||||
genericName: z.string().trim().max(100).nullable().optional(),
|
genericName: z.string().trim().max(100).nullable().optional(),
|
||||||
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
|
||||||
|
medicationForm: medicationFormSchema,
|
||||||
|
pillForm: pillFormSchema.nullable().optional(),
|
||||||
|
lifecycleCategory: lifecycleCategorySchema,
|
||||||
packageType: packageTypeSchema,
|
packageType: packageTypeSchema,
|
||||||
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),
|
||||||
|
packageAmountValue: z.number().int().min(0).default(0),
|
||||||
|
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
|
||||||
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
|
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||||
doseUnit: doseUnitSchema,
|
doseUnit: doseUnitSchema,
|
||||||
medicationStartDate: medicationStartDateSchema,
|
medicationStartDate: medicationStartDateSchema,
|
||||||
|
medicationEndDate: medicationEndDateSchema,
|
||||||
|
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
|
||||||
expiryDate: z.string().nullable().optional(),
|
expiryDate: z.string().nullable().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
prescriptionEnabled: z.boolean().default(false),
|
prescriptionEnabled: z.boolean().default(false),
|
||||||
@@ -62,6 +125,10 @@ const medicationSchema = z
|
|||||||
intakes: z.array(intakeSchema).min(1).max(12).optional(),
|
intakes: z.array(intakeSchema).min(1).max(12).optional(),
|
||||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||||
})
|
})
|
||||||
|
.refine((data) => (data.name && data.name.length > 0) || (data.genericName && data.genericName.length > 0), {
|
||||||
|
message: "Either 'name' or 'genericName' must be provided",
|
||||||
|
path: ["name"],
|
||||||
|
})
|
||||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -76,6 +143,77 @@ const medicationSchema = z
|
|||||||
path: ["medicationStartDate"],
|
path: ["medicationStartDate"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const startDate = data.medicationStartDate ?? "";
|
||||||
|
const endDate = data.medicationEndDate ?? "";
|
||||||
|
if (!startDate || !endDate) return true;
|
||||||
|
return startDate <= endDate;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Medication end date must be on or after medication start date",
|
||||||
|
path: ["medicationEndDate"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
||||||
|
return data.pillForm == null || data.pillForm === "capsule" || data.pillForm === "tablet";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "pillForm must be capsule or tablet for capsule/tablet medications",
|
||||||
|
path: ["pillForm"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "topical") {
|
||||||
|
return isTubePackageType(data.packageType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Topical medications must use tube package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "liquid") {
|
||||||
|
return isLiquidContainerPackageType(data.packageType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Liquid medications must use liquid_container package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
|
||||||
|
return !isTubePackageType(data.packageType) && !isLiquidContainerPackageType(data.packageType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Capsule and tablet medications cannot use tube or liquid_container package type",
|
||||||
|
path: ["packageType"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const schedules = data.intakes ?? data.blisters ?? [];
|
||||||
|
if (data.pillForm !== "capsule") return true;
|
||||||
|
return schedules.every((entry) => Number.isInteger(entry.usage));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Fractional intake is not allowed for capsule",
|
||||||
|
path: ["intakes"],
|
||||||
|
}
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (!data.prescriptionEnabled) return true;
|
if (!data.prescriptionEnabled) return true;
|
||||||
@@ -123,13 +261,33 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
const includeObsolete = request.query.includeObsolete === "true";
|
const includeObsolete = request.query.includeObsolete === "true";
|
||||||
|
const initialRows = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, userId))
|
||||||
|
.orderBy(medications.id);
|
||||||
|
const todayDate = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
for (const row of initialRows) {
|
||||||
|
if (row.isObsolete) continue;
|
||||||
|
if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue;
|
||||||
|
const endDate = row.medicationEndDate?.slice(0, 10);
|
||||||
|
if (!endDate) continue;
|
||||||
|
if (endDate > todayDate) continue;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(medications)
|
||||||
|
.set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(and(eq(medications.id, row.id), eq(medications.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = includeObsolete
|
const whereClause = includeObsolete
|
||||||
? eq(medications.userId, userId)
|
? eq(medications.userId, userId)
|
||||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
// Parse intakes from new format, falling back to legacy
|
// Parse intakes from new format, falling back to legacy
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesWithUnits(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
row.intakeRemindersEnabled ?? false
|
row.intakeRemindersEnabled ?? false
|
||||||
@@ -140,10 +298,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
genericName: row.genericName,
|
genericName: row.genericName,
|
||||||
takenBy: parseTakenByJson(row.takenByJson),
|
takenBy: parseTakenByJson(row.takenByJson),
|
||||||
packageType: row.packageType ?? "blister",
|
medicationForm: row.medicationForm ?? "tablet",
|
||||||
|
pillForm: row.pillForm ?? null,
|
||||||
|
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(row.packageType),
|
||||||
packCount: row.packCount ?? 1,
|
packCount: row.packCount ?? 1,
|
||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
|
packageAmountValue: row.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: row.totalPills ?? null,
|
totalPills: row.totalPills ?? null,
|
||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
stockAdjustment: row.stockAdjustment ?? 0,
|
stockAdjustment: row.stockAdjustment ?? 0,
|
||||||
@@ -151,6 +314,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: row.pillWeightMg,
|
pillWeightMg: row.pillWeightMg,
|
||||||
doseUnit: row.doseUnit ?? "mg",
|
doseUnit: row.doseUnit ?? "mg",
|
||||||
medicationStartDate: row.medicationStartDate || null,
|
medicationStartDate: row.medicationStartDate || null,
|
||||||
|
medicationEndDate: row.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes, // New unified format with per-intake takenBy
|
intakes, // New unified format with per-intake takenBy
|
||||||
// Legacy blisters format (for backward compat with frontend during transition)
|
// Legacy blisters format (for backward compat with frontend during transition)
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
@@ -180,15 +345,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
medicationForm,
|
||||||
|
pillForm,
|
||||||
|
lifecycleCategory,
|
||||||
packageType,
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills,
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
doseUnit,
|
doseUnit,
|
||||||
medicationStartDate,
|
medicationStartDate,
|
||||||
|
medicationEndDate,
|
||||||
|
autoMarkObsoleteAfterEndDate,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
prescriptionEnabled,
|
prescriptionEnabled,
|
||||||
@@ -201,6 +373,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blisters: inputBlisters,
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
|
const normalizedPillForm =
|
||||||
|
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
|
||||||
|
|
||||||
// Convert to unified intakes format
|
// Convert to unified intakes format
|
||||||
let intakes: Intake[];
|
let intakes: Intake[];
|
||||||
if (inputIntakes) {
|
if (inputIntakes) {
|
||||||
@@ -209,6 +384,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: i.usage,
|
usage: i.usage,
|
||||||
every: i.every,
|
every: i.every,
|
||||||
start: i.start,
|
start: i.start,
|
||||||
|
intakeUnit: i.intakeUnit ?? null,
|
||||||
takenBy: i.takenBy || null,
|
takenBy: i.takenBy || null,
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -218,6 +394,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // No per-intake takenBy from legacy
|
takenBy: null, // No per-intake takenBy from legacy
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -239,15 +416,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
packageType: packageType ?? "blister",
|
medicationForm: medicationForm ?? "tablet",
|
||||||
|
pillForm: normalizedPillForm,
|
||||||
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(packageType),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills: totalPills || null,
|
totalPills: totalPills || null,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
doseUnit: doseUnit ?? "mg",
|
doseUnit: doseUnit ?? "mg",
|
||||||
medicationStartDate: medicationStartDate ?? "",
|
medicationStartDate: medicationStartDate ?? "",
|
||||||
|
medicationEndDate: medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||||
@@ -268,10 +452,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: inserted.name,
|
name: inserted.name,
|
||||||
genericName: inserted.genericName,
|
genericName: inserted.genericName,
|
||||||
takenBy: parseTakenByJson(inserted.takenByJson),
|
takenBy: parseTakenByJson(inserted.takenByJson),
|
||||||
packageType: inserted.packageType ?? "blister",
|
medicationForm: inserted.medicationForm ?? "tablet",
|
||||||
|
pillForm: inserted.pillForm ?? null,
|
||||||
|
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(inserted.packageType),
|
||||||
packCount: inserted.packCount,
|
packCount: inserted.packCount,
|
||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
|
packageAmountValue: inserted.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: inserted.totalPills ?? null,
|
totalPills: inserted.totalPills ?? null,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
stockAdjustment: inserted.stockAdjustment ?? 0,
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
@@ -279,6 +468,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: inserted.pillWeightMg,
|
pillWeightMg: inserted.pillWeightMg,
|
||||||
doseUnit: inserted.doseUnit ?? "mg",
|
doseUnit: inserted.doseUnit ?? "mg",
|
||||||
medicationStartDate: inserted.medicationStartDate || null,
|
medicationStartDate: inserted.medicationStartDate || null,
|
||||||
|
medicationEndDate: inserted.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes,
|
intakes,
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
@@ -315,15 +506,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName,
|
genericName,
|
||||||
takenBy,
|
takenBy,
|
||||||
|
medicationForm,
|
||||||
|
pillForm,
|
||||||
|
lifecycleCategory,
|
||||||
packageType,
|
packageType,
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
totalPills,
|
totalPills,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg,
|
pillWeightMg,
|
||||||
doseUnit,
|
doseUnit,
|
||||||
medicationStartDate,
|
medicationStartDate,
|
||||||
|
medicationEndDate,
|
||||||
|
autoMarkObsoleteAfterEndDate,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
notes,
|
notes,
|
||||||
prescriptionEnabled,
|
prescriptionEnabled,
|
||||||
@@ -336,6 +534,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blisters: inputBlisters,
|
blisters: inputBlisters,
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
|
const normalizedPillForm =
|
||||||
|
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
|
||||||
|
|
||||||
// Convert to unified intakes format
|
// Convert to unified intakes format
|
||||||
let intakes: Intake[];
|
let intakes: Intake[];
|
||||||
if (inputIntakes) {
|
if (inputIntakes) {
|
||||||
@@ -344,6 +545,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: i.usage,
|
usage: i.usage,
|
||||||
every: i.every,
|
every: i.every,
|
||||||
start: i.start,
|
start: i.start,
|
||||||
|
intakeUnit: i.intakeUnit ?? null,
|
||||||
takenBy: i.takenBy || null,
|
takenBy: i.takenBy || null,
|
||||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -353,6 +555,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // No per-intake takenBy from legacy
|
takenBy: null, // No per-intake takenBy from legacy
|
||||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -384,15 +587,22 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name,
|
name,
|
||||||
genericName: genericName || null,
|
genericName: genericName || null,
|
||||||
takenByJson,
|
takenByJson,
|
||||||
packageType: packageType ?? "blister",
|
medicationForm: medicationForm ?? "tablet",
|
||||||
|
pillForm: normalizedPillForm,
|
||||||
|
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(packageType),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
pillsPerBlister,
|
pillsPerBlister,
|
||||||
totalPills: totalPills || null,
|
totalPills: totalPills || null,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit,
|
||||||
looseTablets,
|
looseTablets,
|
||||||
pillWeightMg: pillWeightMg || null,
|
pillWeightMg: pillWeightMg || null,
|
||||||
doseUnit: doseUnit ?? "mg",
|
doseUnit: doseUnit ?? "mg",
|
||||||
medicationStartDate: medicationStartDate ?? "",
|
medicationStartDate: medicationStartDate ?? "",
|
||||||
|
medicationEndDate: medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
|
||||||
expiryDate: expiryDate || null,
|
expiryDate: expiryDate || null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||||
@@ -417,7 +627,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
// Migrate dose tracking IDs when intake schedule changes
|
// Migrate dose tracking IDs when intake schedule changes
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Parse old intakes from the existing medication row
|
// Parse old intakes from the existing medication row
|
||||||
const oldIntakes = parseIntakesJson(
|
const oldIntakes = parseIntakesWithUnits(
|
||||||
existing.intakesJson,
|
existing.intakesJson,
|
||||||
{ usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson },
|
{ usageJson: existing.usageJson, everyJson: existing.everyJson, startJson: existing.startJson },
|
||||||
existing.intakeRemindersEnabled
|
existing.intakeRemindersEnabled
|
||||||
@@ -537,10 +747,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
name: result[0].name,
|
name: result[0].name,
|
||||||
genericName: result[0].genericName,
|
genericName: result[0].genericName,
|
||||||
takenBy: parseTakenByJson(result[0].takenByJson),
|
takenBy: parseTakenByJson(result[0].takenByJson),
|
||||||
packageType: result[0].packageType ?? "blister",
|
medicationForm: result[0].medicationForm ?? "tablet",
|
||||||
|
pillForm: result[0].pillForm ?? null,
|
||||||
|
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(result[0].packageType),
|
||||||
packCount: result[0].packCount,
|
packCount: result[0].packCount,
|
||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
|
packageAmountValue: result[0].packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g",
|
||||||
totalPills: result[0].totalPills ?? null,
|
totalPills: result[0].totalPills ?? null,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
@@ -548,6 +763,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
pillWeightMg: result[0].pillWeightMg,
|
pillWeightMg: result[0].pillWeightMg,
|
||||||
doseUnit: result[0].doseUnit ?? "mg",
|
doseUnit: result[0].doseUnit ?? "mg",
|
||||||
medicationStartDate: result[0].medicationStartDate || null,
|
medicationStartDate: result[0].medicationStartDate || null,
|
||||||
|
medicationEndDate: result[0].medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true,
|
||||||
intakes,
|
intakes,
|
||||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
@@ -623,62 +840,101 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
// Stock correction endpoint - updates stockAdjustment and optionally base amount fields for amount-based corrections
|
||||||
// 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; looseTablets?: number } }>(
|
app.patch<{
|
||||||
"/medications/:id/stock-adjustment",
|
Params: { id: string };
|
||||||
async (req, reply) => {
|
Body: {
|
||||||
const idNum = Number(req.params.id);
|
stockAdjustment: number;
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
looseTablets?: number;
|
||||||
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
packCount?: number;
|
||||||
|
};
|
||||||
|
}>("/medications/:id/stock-adjustment", async (req, reply) => {
|
||||||
|
const idNum = Number(req.params.id);
|
||||||
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.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, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
const { stockAdjustment, looseTablets, totalPills, packageAmountValue, packCount } = req.body as {
|
||||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
stockAdjustment: number;
|
||||||
if (
|
looseTablets?: number;
|
||||||
looseTablets !== undefined &&
|
totalPills?: number;
|
||||||
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
packageAmountValue?: number;
|
||||||
) {
|
packCount?: number;
|
||||||
return reply.badRequest("looseTablets must be a non-negative integer");
|
};
|
||||||
}
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
if (
|
||||||
const updateFields: {
|
looseTablets !== undefined &&
|
||||||
stockAdjustment: number;
|
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||||
lastStockCorrectionAt: Date;
|
) {
|
||||||
updatedAt: Date;
|
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||||
looseTablets?: number;
|
|
||||||
} = {
|
|
||||||
stockAdjustment,
|
|
||||||
lastStockCorrectionAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
if (looseTablets !== undefined) {
|
|
||||||
updateFields.looseTablets = looseTablets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.update(medications)
|
|
||||||
.set(updateFields)
|
|
||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!result.length) return reply.notFound();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: result[0].id,
|
|
||||||
stockAdjustment: result[0].stockAdjustment ?? 0,
|
|
||||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
|
||||||
updatedAt: result[0].updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
);
|
if (
|
||||||
|
totalPills !== undefined &&
|
||||||
|
(typeof totalPills !== "number" || !Number.isInteger(totalPills) || totalPills < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("totalPills must be a non-negative integer");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
packageAmountValue !== undefined &&
|
||||||
|
(typeof packageAmountValue !== "number" || !Number.isInteger(packageAmountValue) || packageAmountValue < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("packageAmountValue must be a non-negative integer");
|
||||||
|
}
|
||||||
|
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) {
|
||||||
|
return reply.badRequest("packCount must be an integer >= 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: {
|
||||||
|
stockAdjustment: number;
|
||||||
|
lastStockCorrectionAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
looseTablets?: number;
|
||||||
|
totalPills?: number | null;
|
||||||
|
packageAmountValue?: number;
|
||||||
|
packCount?: number;
|
||||||
|
} = {
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageType = normalizePackageType(existing.packageType);
|
||||||
|
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
|
if (allowsAmountBaseUpdate) {
|
||||||
|
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
||||||
|
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
||||||
|
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||||
|
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||||
|
}
|
||||||
|
if (looseTablets !== undefined) {
|
||||||
|
updateFields.looseTablets = looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(medications)
|
||||||
|
.set(updateFields)
|
||||||
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!result.length) return reply.notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result[0].id,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
|
updatedAt: result[0].updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
@@ -693,10 +949,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)
|
||||||
@@ -719,24 +972,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)
|
||||||
@@ -758,10 +1018,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)
|
||||||
@@ -792,26 +1049,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
|
||||||
@@ -819,87 +1088,137 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const payload = rows.map((row) => {
|
const payload = rows.map((row) => {
|
||||||
// Parse intakes from new format, falling back to legacy
|
// Parse intakes from new format, falling back to legacy
|
||||||
const intakes = parseIntakesJson(
|
const intakes = parseIntakesWithUnits(
|
||||||
row.intakesJson,
|
row.intakesJson,
|
||||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
row.intakeRemindersEnabled ?? false
|
row.intakeRemindersEnabled ?? false
|
||||||
);
|
);
|
||||||
const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
const medForm = row.medicationForm ?? "tablet";
|
||||||
|
const blisters = intakes.map((i) => ({
|
||||||
|
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
const looseTablets = row.looseTablets ?? 0;
|
const looseTablets = row.looseTablets ?? 0;
|
||||||
const stockAdjustment = row.stockAdjustment ?? 0;
|
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||||
const packageType = row.packageType ?? "blister";
|
const packageType = normalizePackageType(row.packageType);
|
||||||
|
|
||||||
// For bottle type, looseTablets IS the current stock (no blister math)
|
// For bottle type, looseTablets IS the current stock (no blister math)
|
||||||
const originalTotalPills =
|
const isTopical = medForm === "topical" || isTubePackageType(packageType);
|
||||||
packageType === "bottle"
|
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||||
? 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;
|
||||||
|
|
||||||
blisters.forEach((blister, blisterIdx) => {
|
if (isTopical) {
|
||||||
const blisterStart = parseLocalDateTime(blister.start);
|
consumedUntilNow = 0;
|
||||||
if (Number.isNaN(blisterStart.getTime())) return;
|
} else if (stockCalculationMode === "automatic") {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
const period = Math.max(1, blister.every) * msPerDay;
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
// After a stock correction, start counting from the NEXT scheduled
|
let effectiveStart: number;
|
||||||
// dose, because the user's pill count already reflects all
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
// consumption up to the correction time.
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
let effectiveStart: number;
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
effectiveStart = stockCorrectionCutoff + period;
|
} else {
|
||||||
} else {
|
effectiveStart = blisterStart;
|
||||||
effectiveStart = blisterStart.getTime();
|
}
|
||||||
}
|
|
||||||
if (effectiveStart > now.getTime()) return;
|
|
||||||
|
|
||||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intakePerson) {
|
||||||
|
peopleForThisIntake = [intakePerson];
|
||||||
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
// Get the people for this intake (from intakes array or medication takenBy)
|
let timeBasedConsumed = 0;
|
||||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
let lastAutoConsumedDateMs = 0;
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
|
||||||
const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null];
|
|
||||||
const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback;
|
|
||||||
|
|
||||||
// Generate expected dose IDs and check if they're taken
|
if (effectiveStart <= now.getTime()) {
|
||||||
for (let i = 0; i < occurrences; i++) {
|
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||||
const doseDate = new Date(effectiveStart + i * period);
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
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
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
for (const person of peopleForThisIntake) {
|
lastAutoConsumedDateMs = new Date(
|
||||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
lastDoseTime.getFullYear(),
|
||||||
if (takenDoseIds.has(doseId)) {
|
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 = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow);
|
||||||
|
|
||||||
// Calculate usage for the planning period
|
// Calculate usage for the planning period
|
||||||
// Always use the user-selected start date for the usage calculation.
|
// Always use the user-selected start date for the usage calculation.
|
||||||
@@ -909,7 +1228,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
// The stock already reflects consumed doses, so no double-counting occurs.
|
// The stock already reflects consumed doses, so no double-counting occurs.
|
||||||
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
|
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
|
||||||
const effectivePlannerStart = includeUntilStart ? now : start;
|
const effectivePlannerStart = includeUntilStart ? now : start;
|
||||||
const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end);
|
const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end);
|
||||||
|
|
||||||
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
|
||||||
|
|
||||||
@@ -919,7 +1238,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
let fullBlisters: number;
|
let fullBlisters: number;
|
||||||
let loosePills: number;
|
let loosePills: number;
|
||||||
|
|
||||||
if (packageType === "bottle") {
|
if (isAmountBasedPackageType(packageType)) {
|
||||||
// Bottle type: no blisters, everything is loose pills
|
// Bottle type: no blisters, everything is loose pills
|
||||||
fullBlisters = 0;
|
fullBlisters = 0;
|
||||||
loosePills = availableAfterPeriod;
|
loosePills = availableAfterPeriod;
|
||||||
@@ -943,6 +1262,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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
return reply.redirect(authUrl.href);
|
return reply.redirect(authUrl.href);
|
||||||
} catch (err: unknown) {
|
} 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);
|
||||||
@@ -174,7 +174,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +217,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
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: unknown) {
|
} 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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+234
-15
@@ -15,6 +15,12 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
|||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
getPlannerUnitKind,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
import { loadUserSettings, sendShoutrrrNotification } from "./settings.js";
|
||||||
|
|
||||||
// Escape HTML to prevent XSS in email templates
|
// Escape HTML to prevent XSS in email templates
|
||||||
@@ -29,6 +35,43 @@ function escapeHtml(text: string): string {
|
|||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split("@");
|
||||||
|
if (!domain) return "invalid-email";
|
||||||
|
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||||
|
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
type PlannerRow = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
@@ -42,6 +85,17 @@ type PlannerRow = {
|
|||||||
packageType?: string;
|
packageType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isContainerPackage(packageType?: string): boolean {
|
||||||
|
return isAmountBasedPackageType(packageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlannerUnit(packageType: string | undefined, tr: ReturnType<typeof getTranslations>): string {
|
||||||
|
const unitKind = getPlannerUnitKind(packageType);
|
||||||
|
if (unitKind === "units") return tr.common.units;
|
||||||
|
if (unitKind === "ml") return tr.common.ml;
|
||||||
|
return tr.common.pills;
|
||||||
|
}
|
||||||
|
|
||||||
type SendEmailBody = {
|
type SendEmailBody = {
|
||||||
email: string;
|
email: string;
|
||||||
from: string;
|
from: string;
|
||||||
@@ -96,6 +150,10 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
// Demand calculator notification (supports email and push)
|
// Demand calculator notification (supports email and push)
|
||||||
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
|
||||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), rowCount: rows?.length ?? 0 },
|
||||||
|
"[Planner] Demand notification request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing planner data" });
|
return reply.status(400).send({ error: "Missing planner data" });
|
||||||
@@ -110,6 +168,7 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||||
if (activeRows.length === 0) {
|
if (activeRows.length === 0) {
|
||||||
|
request.log.warn("[Planner] Demand notification skipped: no active medications in request");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +178,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||||
};
|
};
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
emailEnabled: notificationSettings.emailEnabled,
|
||||||
|
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||||
|
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||||
|
activeRowCount: activeRows.length,
|
||||||
|
},
|
||||||
|
"[Planner] Demand notification channel state"
|
||||||
|
);
|
||||||
|
|
||||||
// Get locale from user settings or use the language passed in the body
|
// Get locale from user settings or use the language passed in the body
|
||||||
const language: Language = (userSettings.language as Language) || bodyLanguage || "en";
|
const language: Language = (userSettings.language as Language) || bodyLanguage || "en";
|
||||||
@@ -168,16 +237,18 @@ ${summaryText}
|
|||||||
|
|
||||||
${activeRows
|
${activeRows
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const isBottle = r.packageType === "bottle";
|
const isBottle = isContainerPackage(r.packageType);
|
||||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
const usageUnit = getPlannerUnit(r.packageType, tr);
|
||||||
|
const usage = `${r.plannerUsage} ${usageUnit}`;
|
||||||
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
||||||
const medPrescription = prescriptionMap.get(r.medicationId);
|
const medPrescription = prescriptionMap.get(r.medicationId);
|
||||||
const rxRefills = medPrescription?.prescriptionEnabled
|
const rxRefills = medPrescription?.prescriptionEnabled
|
||||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||||
: dc.prescriptionNotApplicable;
|
: dc.prescriptionNotApplicable;
|
||||||
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
||||||
|
const availableUnit = getPlannerUnit(r.packageType, tr);
|
||||||
const available = isBottle
|
const available = isBottle
|
||||||
? `${loosePills} ${tr.common.pills}`
|
? `${loosePills} ${availableUnit}`
|
||||||
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
||||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||||
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
||||||
@@ -198,6 +269,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[Planner] Demand email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
// Build HTML table with horizontal scroll for mobile
|
// Build HTML table with horizontal scroll for mobile
|
||||||
// Escape/coerce all user-provided values to prevent XSS
|
// Escape/coerce all user-provided values to prevent XSS
|
||||||
@@ -209,7 +293,7 @@ ${getFooterPlain(language)}`;
|
|||||||
const safeBlisterSize = Number(row.blisterSize) || 0;
|
const safeBlisterSize = Number(row.blisterSize) || 0;
|
||||||
const safeFullBlisters = Number(row.fullBlisters) || 0;
|
const safeFullBlisters = Number(row.fullBlisters) || 0;
|
||||||
const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10;
|
const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10;
|
||||||
const isBottle = row.packageType === "bottle";
|
const isBottle = isContainerPackage(row.packageType);
|
||||||
|
|
||||||
// "Blisters needed" column: dash for bottles
|
// "Blisters needed" column: dash for bottles
|
||||||
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
||||||
@@ -223,7 +307,8 @@ ${getFooterPlain(language)}`;
|
|||||||
// "Available" column: match frontend format
|
// "Available" column: match frontend format
|
||||||
let availableCell: string;
|
let availableCell: string;
|
||||||
if (isBottle) {
|
if (isBottle) {
|
||||||
availableCell = `${safeLoosePills} ${tr.common.pills}`;
|
const availableUnit = getPlannerUnit(row.packageType, tr);
|
||||||
|
availableCell = `${safeLoosePills} ${availableUnit}`;
|
||||||
} else {
|
} else {
|
||||||
availableCell = `${safeFullBlisters} ${tr.common.blisters}`;
|
availableCell = `${safeFullBlisters} ${tr.common.blisters}`;
|
||||||
if (safeLoosePills > 0) {
|
if (safeLoosePills > 0) {
|
||||||
@@ -236,7 +321,7 @@ ${getFooterPlain(language)}`;
|
|||||||
return `
|
return `
|
||||||
<tr style="${rowBg}">
|
<tr style="${rowBg}">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${getPlannerUnit(row.packageType, tr)}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
||||||
@@ -303,7 +388,9 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[Planner] Sending demand email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||||
@@ -311,12 +398,33 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Planner] Demand email sent");
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[Planner] Demand email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[Planner] Demand email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{ emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) },
|
||||||
|
"[Planner] Demand email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
@@ -324,7 +432,7 @@ ${getFooterPlain(language)}`;
|
|||||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||||
const pushMessage = `${summaryText}\n\n${activeRows
|
const pushMessage = `${summaryText}\n\n${activeRows
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
const usage = `${r.plannerUsage} ${getPlannerUnit(r.packageType, tr)}`;
|
||||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||||
return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`;
|
return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`;
|
||||||
})
|
})
|
||||||
@@ -363,6 +471,10 @@ ${getFooterPlain(language)}`;
|
|||||||
// Reminder notification for low stock medications (supports email and push)
|
// Reminder notification for low stock medications (supports email and push)
|
||||||
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => {
|
||||||
const { email, lowStock } = request.body;
|
const { email, lowStock } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), lowStockCount: lowStock?.length ?? 0 },
|
||||||
|
"[ReminderManual] Stock reminder request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!lowStock || lowStock.length === 0) {
|
if (!lowStock || lowStock.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing low stock data" });
|
return reply.status(400).send({ error: "Missing low stock data" });
|
||||||
@@ -371,12 +483,22 @@ ${getFooterPlain(language)}`;
|
|||||||
// Load user settings
|
// Load user settings
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
const activeMeds = await db
|
const activeMeds = await db
|
||||||
.select({ name: medications.name })
|
.select({ name: medications.name, genericName: medications.genericName, packageType: medications.packageType })
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
const activeMedicationByName = new Map(
|
||||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
activeMeds
|
||||||
|
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
|
||||||
|
.filter(([name]) => name.length > 0)
|
||||||
|
);
|
||||||
|
const filteredLowStock = lowStock.filter((item) => {
|
||||||
|
const packageType = activeMedicationByName.get(item.name);
|
||||||
|
if (!packageType) return false;
|
||||||
|
if (isTubePackageType(packageType)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
if (filteredLowStock.length === 0) {
|
if (filteredLowStock.length === 0) {
|
||||||
|
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +508,16 @@ ${getFooterPlain(language)}`;
|
|||||||
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
shoutrrrEnabled: userSettings.shoutrrrEnabled,
|
||||||
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
shoutrrrUrl: userSettings.shoutrrrUrl || "",
|
||||||
};
|
};
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
emailEnabled: notificationSettings.emailEnabled,
|
||||||
|
pushEnabled: notificationSettings.shoutrrrEnabled,
|
||||||
|
hasPushUrl: Boolean(notificationSettings.shoutrrrUrl),
|
||||||
|
filteredLowStockCount: filteredLowStock.length,
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock reminder channel state"
|
||||||
|
);
|
||||||
|
|
||||||
// Get translations based on user language
|
// Get translations based on user language
|
||||||
const language = (userSettings.language as Language) || "en";
|
const language = (userSettings.language as Language) || "en";
|
||||||
@@ -457,6 +589,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
// Build subject line from shared title parts
|
// Build subject line from shared title parts
|
||||||
const subjectText = titleParts.join(", ");
|
const subjectText = titleParts.join(", ");
|
||||||
@@ -570,7 +715,9 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending stock reminder email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `MedAssist-ng: ${subjectText}`,
|
subject: `MedAssist-ng: ${subjectText}`,
|
||||||
@@ -578,12 +725,36 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||||
|
"[ReminderManual] Stock reminder email sent"
|
||||||
|
);
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Stock reminder email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Stock reminder email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{ emailEnabled: notificationSettings.emailEnabled, hasRecipient: Boolean(email) },
|
||||||
|
"[ReminderManual] Stock email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
@@ -634,6 +805,10 @@ ${getFooterPlain(language)}`;
|
|||||||
// Manual prescription reminder (supports email and push)
|
// Manual prescription reminder (supports email and push)
|
||||||
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
|
app.post<{ Body: PrescriptionReminderBody }>("/reminder/send-prescription", async (request, reply) => {
|
||||||
const { email, prescriptionLow } = request.body;
|
const { email, prescriptionLow } = request.body;
|
||||||
|
request.log.info(
|
||||||
|
{ hasEmail: Boolean(email), prescriptionCount: prescriptionLow?.length ?? 0 },
|
||||||
|
"[ReminderManual] Prescription reminder request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!prescriptionLow || prescriptionLow.length === 0) {
|
if (!prescriptionLow || prescriptionLow.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing prescription reminder data" });
|
return reply.status(400).send({ error: "Missing prescription reminder data" });
|
||||||
@@ -641,12 +816,13 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
const activeMeds = await db
|
const activeMeds = await db
|
||||||
.select({ name: medications.name })
|
.select({ name: medications.name, genericName: medications.genericName })
|
||||||
.from(medications)
|
.from(medications)
|
||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
|
||||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||||
if (filteredPrescriptionLow.length === 0) {
|
if (filteredPrescriptionLow.length === 0) {
|
||||||
|
request.log.warn("[ReminderManual] Prescription reminder skipped: no active medications after filtering");
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +860,19 @@ ${getFooterPlain(language)}`;
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription email path selected"
|
||||||
|
);
|
||||||
|
|
||||||
if (smtpHost && smtpUser) {
|
if (smtpHost && smtpUser) {
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
@@ -767,7 +956,9 @@ ${getFooterPlain(language)}`;
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[ReminderManual] Sending prescription reminder email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -775,12 +966,40 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ to: maskEmail(email), messageId: mailResult.messageId },
|
||||||
|
"[ReminderManual] Prescription reminder email sent"
|
||||||
|
);
|
||||||
results.email = true;
|
results.email = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[ReminderManual] Prescription reminder email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
results.errors.push(`Email: ${errorMessage}`);
|
results.errors.push(`Email: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.warn(
|
||||||
|
{
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
to: maskEmail(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription reminder email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
emailEnabled: userSettings.emailEnabled,
|
||||||
|
emailPrescriptionReminders: userSettings.emailPrescriptionReminders,
|
||||||
|
hasRecipient: Boolean(email),
|
||||||
|
},
|
||||||
|
"[ReminderManual] Prescription email channel not active"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
if (userSettings.shoutrrrEnabled && userSettings.shoutrrrPrescriptionReminders && userSettings.shoutrrrUrl) {
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const newPackCount = med.packCount + effectivePacksAdded;
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
|
let consumedRefills = 0;
|
||||||
|
if (usePrescription) {
|
||||||
|
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||||
|
}
|
||||||
const newRemainingRefills = usePrescription
|
const newRemainingRefills = usePrescription
|
||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|||||||
@@ -51,17 +51,22 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
doseId: doseTracking.doseId,
|
doseId: doseTracking.doseId,
|
||||||
takenAt: doseTracking.takenAt,
|
takenAt: doseTracking.takenAt,
|
||||||
dismissed: doseTracking.dismissed,
|
dismissed: doseTracking.dismissed,
|
||||||
|
takenSource: doseTracking.takenSource,
|
||||||
})
|
})
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(eq(doseTracking.userId, userId));
|
.where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
// Group doses by medication ID
|
// Group doses by medication ID
|
||||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean }[]>();
|
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||||
for (const dose of allDoses) {
|
for (const dose of allDoses) {
|
||||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, dismissed: dose.dismissed });
|
dosesByMed.get(medId)!.push({
|
||||||
|
takenAt: dose.takenAt,
|
||||||
|
dismissed: dose.dismissed,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch refill history for requested medications
|
// Fetch refill history for requested medications
|
||||||
@@ -69,6 +74,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesDismissed: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
@@ -79,6 +85,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
for (const medId of medicationIds) {
|
for (const medId of medicationIds) {
|
||||||
const doses = dosesByMed.get(medId) ?? [];
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
@@ -88,6 +95,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
result[medId] = {
|
result[medId] = {
|
||||||
dosesTaken: takenDoses.length,
|
dosesTaken: takenDoses.length,
|
||||||
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
dosesDismissed: dismissedDoses.length,
|
dosesDismissed: dismissedDoses.length,
|
||||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
|
|||||||
+307
-37
@@ -85,6 +85,58 @@ type TestShoutrrrBody = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maskEmail(email: string): string {
|
||||||
|
const [localPart, domain] = email.split("@");
|
||||||
|
if (!domain) return "invalid-email";
|
||||||
|
if (localPart.length <= 2) return `${localPart[0] ?? "*"}*@${domain}`;
|
||||||
|
return `${localPart.slice(0, 2)}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationProvider(url: string): string {
|
||||||
|
if (url.startsWith("discord://")) return "discord";
|
||||||
|
if (url.startsWith("telegram://")) return "telegram";
|
||||||
|
if (url.startsWith("gotify://")) return "gotify";
|
||||||
|
if (url.startsWith("pushover://")) return "pushover";
|
||||||
|
if (url.startsWith("ntfy://")) return "ntfy";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.hostname || "https";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to parse boolean env vars
|
// Helper to parse boolean env vars
|
||||||
function envBool(key: string, defaultVal: boolean): boolean {
|
function envBool(key: string, defaultVal: boolean): boolean {
|
||||||
const val = process.env[key];
|
const val = process.env[key];
|
||||||
@@ -269,10 +321,13 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get settings for current user
|
// Get settings for current user
|
||||||
app.get("/settings", async (request, reply) => {
|
// Suppress request logs — polled every 30s for reminder status refresh
|
||||||
|
app.get("/settings", { logLevel: "warn" }, async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
const settings = await getOrCreateUserSettings(userId);
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
|
const reminderHour = envInt("REMINDER_HOUR", 6);
|
||||||
|
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
// User notification settings (from DB)
|
// User notification settings (from DB)
|
||||||
@@ -323,6 +378,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
|
||||||
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
|
||||||
// Server settings (from .env, read-only)
|
// Server settings (from .env, read-only)
|
||||||
|
reminderHour,
|
||||||
|
reminderMinutesBefore,
|
||||||
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -420,7 +477,24 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{
|
||||||
|
to: maskEmail(email),
|
||||||
|
hasSmtpHost: Boolean(smtpHost),
|
||||||
|
hasSmtpUser: Boolean(smtpUser),
|
||||||
|
hasSmtpPass: Boolean(smtpPass),
|
||||||
|
hasSmtpFrom: Boolean(smtpFrom),
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
},
|
||||||
|
"[Settings] Test email request received"
|
||||||
|
);
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
if (!smtpHost || !smtpUser) {
|
||||||
|
request.log.warn(
|
||||||
|
{ to: maskEmail(email), hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
||||||
|
"[Settings] Test email skipped: SMTP not configured"
|
||||||
|
);
|
||||||
return reply.status(400).send({ error: "SMTP not configured" });
|
return reply.status(400).send({ error: "SMTP not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +509,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
request.log.info({ to: maskEmail(email) }, "[Settings] Sending test email");
|
||||||
|
|
||||||
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "MedAssist-ng - Test Email",
|
subject: "MedAssist-ng - Test Email",
|
||||||
@@ -451,8 +527,16 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info({ to: maskEmail(email), messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||||
|
|
||||||
return reply.send({ success: true, message: "Test email sent successfully" });
|
return reply.send({ success: true, message: "Test email sent successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error({ error, to: maskEmail(email) }, "[Settings] Test email failed");
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
|
||||||
}
|
}
|
||||||
@@ -467,6 +551,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const provider = getNotificationProvider(url);
|
||||||
const result = await sendShoutrrrNotification(
|
const result = await sendShoutrrrNotification(
|
||||||
url,
|
url,
|
||||||
"MedAssist-ng Test",
|
"MedAssist-ng Test",
|
||||||
@@ -474,11 +559,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||||
} else {
|
} else {
|
||||||
|
request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed");
|
||||||
return reply.status(500).send({ error: result.error });
|
return reply.status(500).send({ error: result.error });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ provider: getNotificationProvider(url), error },
|
||||||
|
"[Settings] Unexpected error while sending test push notification"
|
||||||
|
);
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
|
||||||
}
|
}
|
||||||
@@ -491,6 +582,28 @@ function sanitizeNotificationUrl(
|
|||||||
urlStr: string
|
urlStr: string
|
||||||
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
|
||||||
try {
|
try {
|
||||||
|
// Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID
|
||||||
|
if (urlStr.startsWith("discord://")) {
|
||||||
|
const parsedDiscord = new URL(urlStr);
|
||||||
|
const webhookId = parsedDiscord.hostname;
|
||||||
|
const webhookToken = parsedDiscord.username;
|
||||||
|
|
||||||
|
if (!webhookId || !webhookToken) {
|
||||||
|
return { error: "Invalid Discord URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(webhookId)) {
|
||||||
|
return { error: "Invalid Discord webhook ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
|
||||||
|
return { error: "Invalid Discord webhook token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
|
||||||
|
return { url: discordWebhookUrl, isNtfy: false };
|
||||||
|
}
|
||||||
|
|
||||||
// Convert ntfy:// to https:// for parsing, track if it was ntfy
|
// Convert ntfy:// to https:// for parsing, track if it was ntfy
|
||||||
const isNtfy = urlStr.startsWith("ntfy://");
|
const isNtfy = urlStr.startsWith("ntfy://");
|
||||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||||
@@ -502,38 +615,9 @@ function sanitizeNotificationUrl(
|
|||||||
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
return { error: "Only HTTP/HTTPS protocols are allowed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block private/internal IP addresses
|
const hostValidationError = validateNotificationHostname(parsed.hostname);
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
if (hostValidationError) {
|
||||||
|
return { error: hostValidationError };
|
||||||
// Block localhost
|
|
||||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
|
||||||
return { error: "Localhost URLs are not allowed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block private IP ranges (basic check)
|
|
||||||
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
||||||
if (ipMatch) {
|
|
||||||
const [, a, b] = ipMatch.map(Number);
|
|
||||||
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
|
|
||||||
if (
|
|
||||||
a === 10 ||
|
|
||||||
a === 127 ||
|
|
||||||
(a === 172 && b >= 16 && b <= 31) ||
|
|
||||||
(a === 192 && b === 168) ||
|
|
||||||
(a === 169 && b === 254)
|
|
||||||
) {
|
|
||||||
return { error: "Private IP addresses are not allowed" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block common internal hostnames
|
|
||||||
if (
|
|
||||||
hostname.endsWith(".local") ||
|
|
||||||
hostname.endsWith(".internal") ||
|
|
||||||
hostname.endsWith(".lan") ||
|
|
||||||
hostname === "metadata.google.internal"
|
|
||||||
) {
|
|
||||||
return { error: "Internal hostnames are not allowed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct URL from validated components - this breaks taint tracking
|
// Reconstruct URL from validated components - this breaks taint tracking
|
||||||
@@ -550,6 +634,39 @@ function sanitizeNotificationUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateNotificationHostname(hostnameRaw: string): string | null {
|
||||||
|
const hostname = hostnameRaw.toLowerCase();
|
||||||
|
|
||||||
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||||
|
return "Localhost URLs are not allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (ipMatch) {
|
||||||
|
const [, a, b] = ipMatch.map(Number);
|
||||||
|
if (
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
(a === 169 && b === 254)
|
||||||
|
) {
|
||||||
|
return "Private IP addresses are not allowed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hostname.endsWith(".local") ||
|
||||||
|
hostname.endsWith(".internal") ||
|
||||||
|
hostname.endsWith(".lan") ||
|
||||||
|
hostname === "metadata.google.internal"
|
||||||
|
) {
|
||||||
|
return "Internal hostnames are not allowed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
|
||||||
export async function sendShoutrrrNotification(
|
export async function sendShoutrrrNotification(
|
||||||
urlStr: string,
|
urlStr: string,
|
||||||
@@ -557,6 +674,149 @@ export async function sendShoutrrrNotification(
|
|||||||
message: string
|
message: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
|
if (urlStr.startsWith("pushover://")) {
|
||||||
|
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||||
|
const atIndex = pushoverAuthority.lastIndexOf("@");
|
||||||
|
const credentialPart = atIndex >= 0 ? pushoverAuthority.slice(0, atIndex) : "";
|
||||||
|
const userKey = atIndex >= 0 ? pushoverAuthority.slice(atIndex + 1) : "";
|
||||||
|
|
||||||
|
const tokenSeparatorIndex = credentialPart.indexOf(":");
|
||||||
|
const apiToken = tokenSeparatorIndex >= 0 ? credentialPart.slice(tokenSeparatorIndex + 1) : "";
|
||||||
|
|
||||||
|
const parsedPushover = new URL(urlStr);
|
||||||
|
|
||||||
|
if (!apiToken || !userKey) {
|
||||||
|
return { success: false, error: "Invalid Pushover URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushoverBody = new URLSearchParams({
|
||||||
|
token: apiToken,
|
||||||
|
user: userKey,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = parsedPushover.searchParams.get("devices");
|
||||||
|
if (devices) {
|
||||||
|
pushoverBody.set("device", devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = parsedPushover.searchParams.get("priority");
|
||||||
|
if (priority && /^-?\d+$/.test(priority)) {
|
||||||
|
pushoverBody.set("priority", priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: pushoverBody.toString(),
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) return { success: true };
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.startsWith("telegram://")) {
|
||||||
|
const parsedTelegram = new URL(urlStr);
|
||||||
|
const token = parsedTelegram.username;
|
||||||
|
if (!token || parsedTelegram.hostname !== "telegram") {
|
||||||
|
return { success: false, error: "Invalid Telegram URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatsRaw = parsedTelegram.searchParams.get("chats") ?? parsedTelegram.searchParams.get("channels") ?? "";
|
||||||
|
const chats = chatsRaw
|
||||||
|
.split(",")
|
||||||
|
.map((chat) => chat.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (chats.length === 0) {
|
||||||
|
return { success: false, error: "Telegram URL requires chats parameter" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseModeRaw = parsedTelegram.searchParams.get("parseMode")?.toLowerCase();
|
||||||
|
let parseMode: "HTML" | "Markdown" | "MarkdownV2" | undefined;
|
||||||
|
if (parseModeRaw === "html") {
|
||||||
|
parseMode = "HTML";
|
||||||
|
} else if (parseModeRaw === "markdown") {
|
||||||
|
parseMode = "Markdown";
|
||||||
|
} else if (parseModeRaw === "markdownv2") {
|
||||||
|
parseMode = "MarkdownV2";
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationRaw = parsedTelegram.searchParams.get("notification")?.toLowerCase();
|
||||||
|
const disableNotification = notificationRaw === "no" || notificationRaw === "false";
|
||||||
|
|
||||||
|
const previewRaw = parsedTelegram.searchParams.get("preview")?.toLowerCase();
|
||||||
|
const disablePreview = previewRaw === "no" || previewRaw === "false";
|
||||||
|
|
||||||
|
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
|
||||||
|
return { success: false, error: "Invalid Telegram token format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramSendMessageUrl = new URL("/bot/sendMessage", "https://api.telegram.org");
|
||||||
|
telegramSendMessageUrl.pathname = `/bot${token}/sendMessage`;
|
||||||
|
|
||||||
|
for (const chatId of chats) {
|
||||||
|
const payload: Record<string, string | boolean> = {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: `${title}\n\n${message}`,
|
||||||
|
disable_notification: disableNotification,
|
||||||
|
disable_web_page_preview: disablePreview,
|
||||||
|
};
|
||||||
|
if (parseMode) {
|
||||||
|
payload.parse_mode = parseMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// codeql[js/request-forgery]: host is fixed to api.telegram.org and token is pattern-validated.
|
||||||
|
const response = await fetch(telegramSendMessageUrl.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlStr.startsWith("gotify://")) {
|
||||||
|
const parsedGotify = new URL(urlStr);
|
||||||
|
const hostValidationError = validateNotificationHostname(parsedGotify.hostname);
|
||||||
|
if (hostValidationError) {
|
||||||
|
return { success: false, error: hostValidationError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = parsedGotify.pathname
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (pathParts.length === 0) {
|
||||||
|
return { success: false, error: "Invalid Gotify URL format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = pathParts[pathParts.length - 1];
|
||||||
|
const basePath = pathParts.slice(0, -1).join("/");
|
||||||
|
|
||||||
|
const disableTlsRaw = parsedGotify.searchParams.get("disabletls")?.toLowerCase();
|
||||||
|
const protocol = disableTlsRaw === "yes" || disableTlsRaw === "true" || disableTlsRaw === "1" ? "http" : "https";
|
||||||
|
|
||||||
|
const gotifyWebhookUrl = `${protocol}://${parsedGotify.host}${basePath ? `/${basePath}` : ""}/message?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
const gotifyPriority = parsedGotify.searchParams.get("priority");
|
||||||
|
const gotifyMessage = gotifyPriority ? `${message}\n\n(priority=${gotifyPriority})` : message;
|
||||||
|
|
||||||
|
// Reuse validated https webhook path to keep a single outbound request sink.
|
||||||
|
return sendShoutrrrNotification(gotifyWebhookUrl, title, gotifyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
|
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
|
||||||
// from validated components, breaking taint tracking
|
// from validated components, breaking taint tracking
|
||||||
const validation = sanitizeNotificationUrl(urlStr);
|
const validation = sanitizeNotificationUrl(urlStr);
|
||||||
@@ -584,14 +844,17 @@ export async function sendShoutrrrNotification(
|
|||||||
// Use JSON format only for known webhook services that require it
|
// Use JSON format only for known webhook services that require it
|
||||||
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
|
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
|
||||||
let isJsonWebhook = false;
|
let isJsonWebhook = false;
|
||||||
|
let isDiscordWebhook = false;
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(sanitizedUrl);
|
const parsedUrl = new URL(sanitizedUrl);
|
||||||
const hostname = parsedUrl.hostname.toLowerCase();
|
const hostname = parsedUrl.hostname.toLowerCase();
|
||||||
const pathname = parsedUrl.pathname.toLowerCase();
|
const pathname = parsedUrl.pathname.toLowerCase();
|
||||||
|
isDiscordWebhook =
|
||||||
|
(hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks");
|
||||||
|
|
||||||
isJsonWebhook =
|
isJsonWebhook =
|
||||||
// Discord webhooks
|
// Discord webhooks
|
||||||
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
|
isDiscordWebhook ||
|
||||||
// Slack webhooks
|
// Slack webhooks
|
||||||
hostname === "hooks.slack.com" ||
|
hostname === "hooks.slack.com" ||
|
||||||
hostname.endsWith(".hooks.slack.com") ||
|
hostname.endsWith(".hooks.slack.com") ||
|
||||||
@@ -621,9 +884,16 @@ export async function sendShoutrrrNotification(
|
|||||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||||
targetUrl = sanitizedUrl;
|
targetUrl = sanitizedUrl;
|
||||||
headers = { "Content-Type": "application/json" };
|
headers = { "Content-Type": "application/json" };
|
||||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
if (isDiscordWebhook) {
|
||||||
|
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Unsupported URL format. Use ntfy://, discord://, pushover://, gotify://, telegram://, or https:// URL",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
|
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
|
||||||
|
|||||||
+45
-17
@@ -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";
|
||||||
@@ -7,6 +7,7 @@ import { medications, shareTokens, userSettings, users } 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 { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
import {
|
import {
|
||||||
getAllTakenByForMedication,
|
getAllTakenByForMedication,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -14,9 +15,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 +23,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 +57,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 +66,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({
|
||||||
@@ -113,10 +120,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills =
|
const totalPills = isAmountBasedPackageType(med.packageType)
|
||||||
(med.packageType ?? "blister") === "bottle"
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
return {
|
return {
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
@@ -125,7 +131,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
imageUrl: med.imageUrl,
|
imageUrl: med.imageUrl,
|
||||||
totalPills,
|
totalPills,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: normalizePackageType(med.packageType),
|
||||||
packCount: med.packCount,
|
packCount: med.packCount,
|
||||||
blistersPerPack: med.blistersPerPack,
|
blistersPerPack: med.blistersPerPack,
|
||||||
looseTablets: med.looseTablets,
|
looseTablets: med.looseTablets,
|
||||||
@@ -197,25 +203,47 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique token (8 bytes = 16 hex chars)
|
// Keep exactly one active share link per person/user.
|
||||||
|
// If a link already exists, return the same token and only update settings.
|
||||||
|
const [existingShare] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareTokens)
|
||||||
|
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||||
|
|
||||||
|
if (existingShare) {
|
||||||
|
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[Share] Reused existing share token (owner=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reused: true,
|
||||||
|
token: existingShare.token,
|
||||||
|
shareUrl: `/share/${existingShare.token}`,
|
||||||
|
expiresAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const token = randomBytes(8).toString("hex");
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,144 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 medDisplayName = med.name || med.genericName || "";
|
||||||
|
const todaysIntakes = getTodaysIntakes(
|
||||||
|
medDisplayName,
|
||||||
|
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[],
|
||||||
@@ -58,7 +196,7 @@ async function sendIntakeReminderEmail(
|
|||||||
repeatIntervalMinutes?: number,
|
repeatIntervalMinutes?: number,
|
||||||
currentCount?: number,
|
currentCount?: number,
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const smtpUser = process.env.SMTP_USER;
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
|
||||||
@@ -202,7 +340,7 @@ ${getFooterPlain(language)}`;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: `💊 ${subject}`,
|
subject: `💊 ${subject}`,
|
||||||
@@ -210,7 +348,16 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
return { success: false, error: deliveryError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messageId: mailResult.messageId,
|
||||||
|
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
@@ -246,6 +393,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;
|
||||||
@@ -261,28 +419,29 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Build medication entries that have at least one reminder-enabled intake.
|
||||||
const rows = await db
|
// Intake-level reminders are the single source of truth.
|
||||||
.select()
|
const reminderEntries = rows
|
||||||
.from(medications)
|
.map((med) => {
|
||||||
.where(eq(medications.userId, settings.userId))
|
const intakes = parseIntakesJson(
|
||||||
.orderBy(medications.id);
|
med.intakesJson,
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
|
||||||
|
return { med, intakes, intakesWithReminders };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.intakesWithReminders.length > 0);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (reminderEntries.length === 0) {
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
|
||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`[IntakeReminder] User ${settings.userId}: Found ${reminderEntries.length} medications with reminders`);
|
||||||
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
|
|
||||||
);
|
|
||||||
|
|
||||||
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 }));
|
||||||
@@ -296,29 +455,15 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||||
for (const med of medsWithReminders) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||||
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
|
|
||||||
const intakes = parseIntakesJson(
|
|
||||||
med.intakesJson,
|
|
||||||
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
|
||||||
med.intakeRemindersEnabled ?? false
|
|
||||||
);
|
|
||||||
// Medication-level takenBy (for fallback/display purposes)
|
// Medication-level takenBy (for fallback/display purposes)
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const medDisplayName = med.name || med.genericName || "";
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
|
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
|
||||||
const intakesWithReminders = intakes.filter((intake, idx) => {
|
|
||||||
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
|
|
||||||
if (!hasReminder) {
|
|
||||||
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
|
|
||||||
}
|
|
||||||
return hasReminder;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -328,7 +473,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
// Always get upcoming intakes (15 min before) for first reminders
|
// Always get upcoming intakes (15 min before) for first reminders
|
||||||
const upcomingIntakes = getUpcomingIntakes(
|
const upcomingIntakes = getUpcomingIntakes(
|
||||||
med.name,
|
medDisplayName,
|
||||||
[intake],
|
[intake],
|
||||||
REMINDER_MINUTES_BEFORE,
|
REMINDER_MINUTES_BEFORE,
|
||||||
medicationTakenBy,
|
medicationTakenBy,
|
||||||
@@ -355,7 +500,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
// If repeat reminders enabled, also check for missed intakes (past the intake time)
|
||||||
if (settings.repeatRemindersEnabled) {
|
if (settings.repeatRemindersEnabled) {
|
||||||
const allTodaysIntakes = getTodaysIntakes(
|
const allTodaysIntakes = getTodaysIntakes(
|
||||||
med.name,
|
medDisplayName,
|
||||||
[intake],
|
[intake],
|
||||||
medicationTakenBy,
|
medicationTakenBy,
|
||||||
med.pillWeightMg,
|
med.pillWeightMg,
|
||||||
@@ -558,7 +703,9 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
emailSuccess = result.success;
|
emailSuccess = result.success;
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
|
logger.info(
|
||||||
|
`[IntakeReminder] User ${settings.userId}: Email sent successfully (to: ${settings.notificationEmail}, messageId: ${result.messageId}, smtp: ${result.smtpResponse})`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
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";
|
||||||
|
import {
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
normalizePackageType,
|
||||||
|
} from "../utils/package-profiles.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
import {
|
import {
|
||||||
type Blister,
|
type Blister,
|
||||||
@@ -19,8 +25,11 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
parseBlisters,
|
normalizeIntakeUsageForStock,
|
||||||
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
|
parseTakenByJson,
|
||||||
type ReminderState,
|
type ReminderState,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
@@ -35,9 +44,89 @@ function escapeHtml(text: string): string {
|
|||||||
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDeliveryInfo = {
|
||||||
|
accepted?: unknown;
|
||||||
|
rejected?: unknown;
|
||||||
|
response?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRecipients(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||||
|
const accepted = normalizeRecipients(info.accepted);
|
||||||
|
const rejected = normalizeRecipients(info.rejected);
|
||||||
|
|
||||||
|
if (accepted.length > 0) return null;
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof info.response === "string" && info.response.trim()) {
|
||||||
|
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMTP did not confirm accepted recipients.";
|
||||||
|
}
|
||||||
|
|
||||||
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 +208,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;
|
||||||
@@ -131,6 +216,12 @@ type LowStockItem = {
|
|||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number } {
|
||||||
|
const lowDays = Math.max(1, Math.floor(baselineDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
return { lowDays, criticalDays };
|
||||||
|
}
|
||||||
|
|
||||||
type PrescriptionReminderItem = {
|
type PrescriptionReminderItem = {
|
||||||
name: string;
|
name: string;
|
||||||
remainingRefills: number;
|
remainingRefills: number;
|
||||||
@@ -142,7 +233,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,25 +242,175 @@ 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 packageType = normalizePackageType(row.packageType);
|
||||||
const totalPills =
|
// Tube stock reminders are intentionally disabled:
|
||||||
(row.packageType ?? "blister") === "bottle"
|
// topical usage in grams cannot be mapped reliably to schedule events.
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
if (isTubePackageType(packageType)) continue;
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
const intakes = parseIntakesJson(
|
||||||
|
row.intakesJson,
|
||||||
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
|
row.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
const blisters: Blister[] = intakes.map((i) => ({
|
||||||
|
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||||
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
|
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
const isCritical = daysLeft <= reminderDaysBefore;
|
const isLiquid = isLiquidContainerPackageType(packageType);
|
||||||
const isLow = daysLeft < lowStockDays;
|
const { lowDays, criticalDays } = isLiquid
|
||||||
|
? getLiquidReminderThresholds(reminderDaysBefore)
|
||||||
|
: { lowDays: lowStockDays, criticalDays: reminderDaysBefore };
|
||||||
|
|
||||||
|
const isCritical = daysLeft <= criticalDays;
|
||||||
|
const isLow = isLiquid ? daysLeft <= lowDays : daysLeft < lowDays;
|
||||||
|
|
||||||
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 +442,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[],
|
||||||
@@ -346,7 +607,7 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
@@ -354,6 +615,11 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
|
if (deliveryError) {
|
||||||
|
throw new Error(deliveryError);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
@@ -362,6 +628,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();
|
||||||
|
|
||||||
@@ -403,172 +678,220 @@ 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) {
|
||||||
logger.info(
|
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
if (!stockSendLock) {
|
||||||
);
|
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||||
|
} else {
|
||||||
let emailSuccess = false;
|
try {
|
||||||
let shoutrrrSuccess = false;
|
logger.info(
|
||||||
|
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||||
if (stockEmailEnabled) {
|
|
||||||
const result = await sendReminderEmail(
|
|
||||||
settings.notificationEmail!,
|
|
||||||
allLowStock,
|
|
||||||
language,
|
|
||||||
settings.repeatDailyReminders
|
|
||||||
);
|
|
||||||
emailSuccess = result.success;
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stockPushEnabled) {
|
|
||||||
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
|
||||||
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
|
||||||
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
|
||||||
|
|
||||||
const titleParts: string[] = [];
|
|
||||||
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
|
||||||
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
|
||||||
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
|
||||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
|
||||||
if (emptyMeds.length > 0) {
|
|
||||||
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
|
||||||
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
|
||||||
}
|
|
||||||
if (criticalMeds.length > 0) {
|
|
||||||
if (messageParts.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
|
||||||
criticalMeds.forEach((m) =>
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (lowStockMeds.length > 0) {
|
|
||||||
if (messageParts.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
|
||||||
lowStockMeds.forEach((m) =>
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
|
||||||
shoutrrrSuccess = result.success;
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
let emailSuccess = false;
|
||||||
const currentState = loadReminderState();
|
let shoutrrrSuccess = false;
|
||||||
const singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "stock",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
if (stockEmailEnabled) {
|
||||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
const result = await sendReminderEmail(
|
||||||
|
settings.notificationEmail!,
|
||||||
|
allLowStock,
|
||||||
|
language,
|
||||||
|
settings.repeatDailyReminders
|
||||||
|
);
|
||||||
|
emailSuccess = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock email: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stockPushEnabled) {
|
||||||
|
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
|
||||||
|
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0 && m.isCritical);
|
||||||
|
const lowStockMeds = allLowStock.filter((m) => m.medsLeft > 0 && !m.isCritical);
|
||||||
|
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
|
||||||
|
if (criticalMeds.length > 0) titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
|
||||||
|
if (lowStockMeds.length > 0) titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
|
||||||
|
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyMeds.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.push.emptySection}:`);
|
||||||
|
emptyMeds.forEach((m) => messageParts.push(` • ${m.name}`));
|
||||||
|
}
|
||||||
|
if (criticalMeds.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
|
||||||
|
criticalMeds.forEach((m) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lowStockMeds.length > 0) {
|
||||||
|
if (messageParts.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
|
||||||
|
lowStockMeds.forEach((m) =>
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
|
shoutrrrSuccess = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`[Reminder] User ${settings.userId}: Failed to send stock push: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userStockNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "stock",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||||
|
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(stockSendLock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
logger.info(
|
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
if (!prescriptionSendLock) {
|
||||||
);
|
logger.debug(
|
||||||
|
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
|
||||||
|
// This blocks duplicate sends when two reminder checks overlap in time.
|
||||||
|
const lockedState = loadReminderState();
|
||||||
|
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
|
||||||
|
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
|
||||||
|
if (!shouldSend) {
|
||||||
|
logger.debug(
|
||||||
|
`[Reminder] User ${settings.userId}: prescription reminder already marked as sent today, skipping`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
const preMarkedNotified =
|
||||||
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
!shouldSend || alreadyNotified
|
||||||
const lines = allPrescriptionLow.map((m) => {
|
? lockedState.notifiedMedications
|
||||||
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
: [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])];
|
||||||
if (m.remainingRefills <= 0) {
|
if (shouldSend && !alreadyNotified) {
|
||||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
saveReminderState({
|
||||||
name: m.name,
|
lastAutoEmailSent: lockedState.lastAutoEmailSent,
|
||||||
expirySuffix,
|
lastAutoEmailDate: lockedState.lastAutoEmailDate,
|
||||||
})}`;
|
lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate,
|
||||||
}
|
notifiedMedications: preMarkedNotified,
|
||||||
return `- ${t(tr.prescriptionReminder.line, {
|
nextScheduledCheck: lockedState.nextScheduledCheck,
|
||||||
name: m.name,
|
lastNotificationType: lockedState.lastNotificationType,
|
||||||
refills: m.remainingRefills,
|
lastNotificationChannel: lockedState.lastNotificationChannel,
|
||||||
expirySuffix,
|
});
|
||||||
})}`;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
let emailSuccess = false;
|
if (shouldSend) {
|
||||||
let shoutrrrSuccess = false;
|
logger.info(
|
||||||
|
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||||
|
);
|
||||||
|
|
||||||
if (prescriptionEmailEnabled) {
|
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
|
||||||
const smtpUser = process.env.SMTP_USER;
|
const lines = allPrescriptionLow.map((m) => {
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
if (m.remainingRefills <= 0) {
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
name: m.name,
|
||||||
|
expirySuffix,
|
||||||
if (smtpHost && smtpUser) {
|
})}`;
|
||||||
try {
|
}
|
||||||
const transporter = nodemailer.createTransport({
|
return `- ${t(tr.prescriptionReminder.line, {
|
||||||
host: smtpHost,
|
name: m.name,
|
||||||
port: smtpPort,
|
refills: m.remainingRefills,
|
||||||
secure: smtpSecure,
|
expirySuffix,
|
||||||
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
})}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const subject =
|
let emailSuccess = false;
|
||||||
allPrescriptionLow.length === 1
|
let shoutrrrSuccess = false;
|
||||||
? tr.prescriptionReminder.subjectSingle
|
|
||||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
|
||||||
|
|
||||||
const bodyText =
|
if (prescriptionEmailEnabled) {
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
const smtpHost = process.env.SMTP_HOST;
|
||||||
const emptyAlert =
|
const smtpUser = process.env.SMTP_USER;
|
||||||
emptyRx.length === 1
|
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||||
const lowAlert =
|
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||||
lowRx.length === 1
|
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
|
||||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
|
||||||
|
|
||||||
const tableRows = allPrescriptionLow
|
if (smtpHost && smtpUser) {
|
||||||
.map((item) => {
|
try {
|
||||||
const isEmpty = item.remainingRefills <= 0;
|
const transporter = nodemailer.createTransport({
|
||||||
const safeName = escapeHtml(item.name);
|
host: smtpHost,
|
||||||
const safeRefills = Number(item.remainingRefills) || 0;
|
port: smtpPort,
|
||||||
const safeThreshold = Number(item.lowThreshold) || 0;
|
secure: smtpSecure,
|
||||||
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
auth: { user: smtpUser, pass: smtpPass ?? "" },
|
||||||
const rowBg = isEmpty ? "#fef2f2" : "white";
|
});
|
||||||
return `
|
|
||||||
|
const subject =
|
||||||
|
allPrescriptionLow.length === 1
|
||||||
|
? tr.prescriptionReminder.subjectSingle
|
||||||
|
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||||
|
|
||||||
|
const bodyText =
|
||||||
|
emptyRx.length > 0
|
||||||
|
? tr.prescriptionReminder.descriptionEmpty
|
||||||
|
: tr.prescriptionReminder.descriptionLow;
|
||||||
|
const emptyAlert =
|
||||||
|
emptyRx.length === 1
|
||||||
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
|
const tableRows = allPrescriptionLow
|
||||||
|
.map((item) => {
|
||||||
|
const isEmpty = item.remainingRefills <= 0;
|
||||||
|
const safeName = escapeHtml(item.name);
|
||||||
|
const safeRefills = Number(item.remainingRefills) || 0;
|
||||||
|
const safeThreshold = Number(item.lowThreshold) || 0;
|
||||||
|
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : "white";
|
||||||
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||||
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
|
||||||
@@ -608,80 +931,107 @@ async function checkAndSendReminderForUser(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
emailSuccess = true;
|
const deliveryError = getDeliveryError(mailResult);
|
||||||
} catch (error) {
|
if (deliveryError) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
throw new Error(deliveryError);
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
}
|
||||||
|
emailSuccess = true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
logger.error(
|
||||||
|
`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prescriptionPushEnabled) {
|
||||||
|
const titleParts: string[] = [];
|
||||||
|
if (emptyRx.length > 0)
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
||||||
|
);
|
||||||
|
if (lowRx.length > 0)
|
||||||
|
titleParts.push(
|
||||||
|
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
||||||
|
);
|
||||||
|
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
||||||
|
|
||||||
|
const messageParts: string[] = [];
|
||||||
|
if (emptyRx.length > 0) {
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
||||||
|
for (const m of emptyRx) {
|
||||||
|
messageParts.push(` • ${m.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lowRx.length > 0) {
|
||||||
|
if (emptyRx.length > 0) messageParts.push("");
|
||||||
|
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
||||||
|
for (const m of lowRx) {
|
||||||
|
messageParts.push(
|
||||||
|
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
|
shoutrrrSuccess = result.success;
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
|
lastAutoEmailDate: today,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: "prescription",
|
||||||
|
lastNotificationChannel: channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||||
|
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||||
|
} else if (!alreadyNotified) {
|
||||||
|
// Roll back pre-mark when both channels failed so retries remain possible.
|
||||||
|
const currentState = loadReminderState();
|
||||||
|
saveReminderState({
|
||||||
|
lastAutoEmailSent: currentState.lastAutoEmailSent,
|
||||||
|
lastAutoEmailDate: currentState.lastAutoEmailDate,
|
||||||
|
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
|
||||||
|
notifiedMedications: currentState.notifiedMedications.filter(
|
||||||
|
(key) => key !== userPrescriptionNotifiedKey
|
||||||
|
),
|
||||||
|
nextScheduledCheck: currentState.nextScheduledCheck,
|
||||||
|
lastNotificationType: currentState.lastNotificationType,
|
||||||
|
lastNotificationChannel: currentState.lastNotificationChannel,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(prescriptionSendLock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prescriptionPushEnabled) {
|
|
||||||
const titleParts: string[] = [];
|
|
||||||
if (emptyRx.length > 0)
|
|
||||||
titleParts.push(
|
|
||||||
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
|
|
||||||
);
|
|
||||||
if (lowRx.length > 0)
|
|
||||||
titleParts.push(
|
|
||||||
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
|
|
||||||
);
|
|
||||||
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
|
|
||||||
|
|
||||||
const messageParts: string[] = [];
|
|
||||||
if (emptyRx.length > 0) {
|
|
||||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
|
|
||||||
for (const m of emptyRx) {
|
|
||||||
messageParts.push(` • ${m.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lowRx.length > 0) {
|
|
||||||
if (emptyRx.length > 0) messageParts.push("");
|
|
||||||
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
|
|
||||||
for (const m of lowRx) {
|
|
||||||
messageParts.push(
|
|
||||||
` • ${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
|
||||||
shoutrrrSuccess = result.success;
|
|
||||||
if (!result.success) {
|
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
|
||||||
const currentState = loadReminderState();
|
|
||||||
const singleChannel = emailSuccess ? "email" : "push";
|
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
|
||||||
saveReminderState({
|
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
|
||||||
lastAutoEmailDate: today,
|
|
||||||
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
|
|
||||||
nextScheduledCheck: currentState.nextScheduledCheck,
|
|
||||||
lastNotificationType: "prescription",
|
|
||||||
lastNotificationChannel: channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
|
||||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let 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);
|
||||||
@@ -706,6 +1056,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)
|
||||||
@@ -713,9 +1068,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}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,9 +1081,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("../db/client.js", () => ({
|
|||||||
vi.mock("../plugins/env.js", () => ({
|
vi.mock("../plugins/env.js", () => ({
|
||||||
env: {
|
env: {
|
||||||
AUTH_ENABLED: true,
|
AUTH_ENABLED: true,
|
||||||
LOCAL_AUTH_ENABLED: true,
|
FORM_LOGIN_ENABLED: true,
|
||||||
REGISTRATION_ENABLED: true,
|
REGISTRATION_ENABLED: true,
|
||||||
OIDC_ENABLED: false,
|
OIDC_ENABLED: false,
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
@@ -144,7 +144,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.authEnabled).toBe(true);
|
expect(data.authEnabled).toBe(true);
|
||||||
expect(data.registrationEnabled).toBe(true);
|
expect(data.registrationEnabled).toBe(true);
|
||||||
expect(data.localAuthEnabled).toBe(true);
|
expect(data.formLoginEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
|
|||||||
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||||
const getDbPaths = vi.fn().mockReturnValue({
|
const getDbPaths = vi.fn().mockReturnValue({
|
||||||
dataDir: "/tmp/medassist-data",
|
dataDir: "/tmp/medassist-data",
|
||||||
dbPath: "/tmp/medassist-data/medassist.db",
|
dbPath: "/tmp/medassist-data/medassist-ng.db",
|
||||||
url: "file:/tmp/medassist-data/medassist.db",
|
url: "file:/tmp/medassist-data/medassist-ng.db",
|
||||||
});
|
});
|
||||||
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||||
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||||
@@ -102,7 +102,7 @@ describe("db/client bootstrap", () => {
|
|||||||
await mod.migrationsReady;
|
await mod.migrationsReady;
|
||||||
|
|
||||||
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||||
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
|
||||||
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -101,6 +106,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -171,6 +178,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
|
||||||
)`,
|
)`,
|
||||||
@@ -867,7 +875,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const json = response.json();
|
const json = response.json();
|
||||||
expect(json.status).toBe("ok");
|
expect(json.status).toBe("ok");
|
||||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1288,7 +1295,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const json = response.json();
|
const json = response.json();
|
||||||
expect(json.status).toBe("ok");
|
expect(json.status).toBe("ok");
|
||||||
expect(typeof json.smtpConfigured).toBe("boolean");
|
expect(typeof json.smtpConfigured).toBe("boolean");
|
||||||
expect(typeof json.shoutrrrConfigured).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2500,10 +2506,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Package Type (bottle vs blister) Tests
|
// Package Type (blister, bottle, liquid_container) Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("Package type handling (bottle vs blister)", () => {
|
describe("Package type handling (blister, bottle, liquid_container)", () => {
|
||||||
const bottleMedication = {
|
const bottleMedication = {
|
||||||
name: "Vitamin D Drops",
|
name: "Vitamin D Drops",
|
||||||
packageType: "bottle",
|
packageType: "bottle",
|
||||||
@@ -2524,6 +2530,18 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const liquidContainerMedication = {
|
||||||
|
name: "Cough Syrup",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 0,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
looseTablets: 180,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
};
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2568,6 +2586,49 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.medications[0].totalPills).toBe(120);
|
expect(data.medications[0].totalPills).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create and return liquid_container type medication", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: liquidContainerMedication,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.packageType).toBe("liquid_container");
|
||||||
|
expect(data.medicationForm).toBe("liquid");
|
||||||
|
expect(data.doseUnit).toBe("ml");
|
||||||
|
expect(data.looseTablets).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: { ...liquidContainerMedication, takenBy: ["Daniel"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||||
|
});
|
||||||
|
expect(shareResponse.statusCode).toBe(200);
|
||||||
|
const { token } = shareResponse.json();
|
||||||
|
|
||||||
|
const scheduleResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduleResponse.statusCode).toBe(200);
|
||||||
|
const data = scheduleResponse.json();
|
||||||
|
expect(data.medications).toHaveLength(1);
|
||||||
|
expect(data.medications[0].packageType).toBe("liquid_container");
|
||||||
|
// Liquid container follows container semantics (stock from looseTablets only).
|
||||||
|
expect(data.medications[0].totalPills).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct totalPills for shared blister medication", async () => {
|
it("should calculate correct totalPills for shared blister medication", async () => {
|
||||||
await app.inject({
|
await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2743,5 +2804,18 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(medsResponse.json()).toHaveLength(1);
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
expect(medsResponse.json()[0].packageType).toBe("blister");
|
expect(medsResponse.json()[0].packageType).toBe("blister");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject liquid medication form with non-liquid package type", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packageType: "bottle",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /import
|
// POST /import
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
|
||||||
app.post("/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
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -95,6 +100,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -165,6 +172,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
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify from "fastify";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type OidcMocks = {
|
type OidcMocks = {
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ async function createSchema(client: Client) {
|
|||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
generic_name text,
|
generic_name text,
|
||||||
taken_by_json text NOT NULL DEFAULT '[]',
|
taken_by_json text NOT NULL DEFAULT '[]',
|
||||||
|
medication_form text NOT NULL DEFAULT 'tablet',
|
||||||
|
pill_form text,
|
||||||
|
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
|
||||||
package_type text NOT NULL DEFAULT 'blister',
|
package_type text NOT NULL DEFAULT 'blister',
|
||||||
|
package_amount_value integer NOT NULL DEFAULT 0,
|
||||||
|
package_amount_unit text NOT NULL DEFAULT 'ml',
|
||||||
pack_count integer NOT NULL DEFAULT 1,
|
pack_count integer NOT NULL DEFAULT 1,
|
||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
@@ -112,6 +117,8 @@ async function createSchema(client: Client) {
|
|||||||
notes text,
|
notes text,
|
||||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||||
medication_start_date text NOT NULL DEFAULT '',
|
medication_start_date text NOT NULL DEFAULT '',
|
||||||
|
medication_end_date text,
|
||||||
|
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
|
||||||
is_obsolete integer NOT NULL DEFAULT 0,
|
is_obsolete integer NOT NULL DEFAULT 0,
|
||||||
obsolete_at integer,
|
obsolete_at integer,
|
||||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||||
@@ -284,7 +291,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -330,7 +337,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -434,7 +441,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -522,7 +529,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -697,7 +704,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -727,7 +734,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -763,7 +770,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -849,7 +856,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -982,7 +989,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1036,6 +1043,36 @@ describe("Planner Routes", () => {
|
|||||||
expect(title).not.toContain("Low");
|
expect(title).not.toContain("Low");
|
||||||
expect(message).toContain("Running critically low");
|
expect(message).toContain("Running critically low");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return 400 when only tube medications are in active meds", async () => {
|
||||||
|
// Insert a tube medication (should be excluded from reminders)
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json, package_type)
|
||||||
|
VALUES (3, 999999999, 'Ointment', '[]', '[]', '[]', '[]', 'tube')`,
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||||
|
args: [999999999],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/reminder/send-email",
|
||||||
|
payload: {
|
||||||
|
email: "test@example.com",
|
||||||
|
lowStock: [{ name: "Ointment", medsLeft: 5, daysLeft: 10, depletionDate: "2025-01-13" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expects 400 because tube medications are excluded from stock reminders
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "No active medications to notify" });
|
||||||
|
expect(mockSendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /reminder/send-prescription", () => {
|
describe("POST /reminder/send-prescription", () => {
|
||||||
@@ -1082,7 +1119,7 @@ describe("Planner Routes", () => {
|
|||||||
args: [999999999],
|
args: [999999999],
|
||||||
});
|
});
|
||||||
|
|
||||||
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -207,7 +207,12 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
process.env.SMTP_HOST = "smtp.example.com";
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
process.env.SMTP_USER = "mailer@example.com";
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
process.env.SMTP_TOKEN = "secret";
|
process.env.SMTP_TOKEN = "secret";
|
||||||
nodemailerSendMail.mockResolvedValue(undefined);
|
nodemailerSendMail.mockResolvedValue({
|
||||||
|
accepted: ["person@example.com"],
|
||||||
|
rejected: [],
|
||||||
|
response: "250 2.0.0 OK",
|
||||||
|
messageId: "test-message-id",
|
||||||
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getLiquidReminderThresholds", () => {
|
||||||
|
// Import the function for testing (test-only export)
|
||||||
|
// The function is: getLiquidReminderThresholds(baselineDays: number): { lowDays: number; criticalDays: number }
|
||||||
|
// Formula: lowDays = baselineDays, criticalDays = ceil(lowDays / 2)
|
||||||
|
|
||||||
|
it("derives critical as ceil(baseline / 2) for typical baseline", () => {
|
||||||
|
// For baseline=7 days: low=7, critical=ceil(7/2)=4
|
||||||
|
const baseline = 7;
|
||||||
|
// Manually apply the formula to verify
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(7);
|
||||||
|
expect(expectedCritical).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives critical correctly at boundary: baseline=1", () => {
|
||||||
|
// For baseline=1: low=1, critical=ceil(1/2)=1 (minimum 1 due to Math.max(1, ...))
|
||||||
|
const baseline = 1;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(1);
|
||||||
|
expect(expectedCritical).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for even baseline (baseline=14)", () => {
|
||||||
|
// For baseline=14: low=14, critical=ceil(14/2)=7
|
||||||
|
const baseline = 14;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(14);
|
||||||
|
expect(expectedCritical).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives thresholds correctly for odd baseline (baseline=15)", () => {
|
||||||
|
// For baseline=15: low=15, critical=ceil(15/2)=8
|
||||||
|
const baseline = 15;
|
||||||
|
const expectedLow = Math.max(1, Math.floor(baseline));
|
||||||
|
const expectedCritical = Math.max(1, Math.ceil(expectedLow / 2));
|
||||||
|
expect(expectedLow).toBe(15);
|
||||||
|
expect(expectedCritical).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
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 };
|
||||||
|
}
|
||||||
@@ -23,18 +23,22 @@ function shouldLog(level: string): boolean {
|
|||||||
return LOG_LEVELS[level] >= getLevel();
|
return LOG_LEVELS[level] >= getLevel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ts(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export const log = {
|
export const log = {
|
||||||
debug(msg: string): void {
|
debug(msg: string): void {
|
||||||
if (shouldLog("debug")) console.log(msg);
|
if (shouldLog("debug")) console.log(`[${ts()}] [DEBUG] ${msg}`);
|
||||||
},
|
},
|
||||||
info(msg: string): void {
|
info(msg: string): void {
|
||||||
if (shouldLog("info")) console.log(msg);
|
if (shouldLog("info")) console.log(`[${ts()}] [INFO] ${msg}`);
|
||||||
},
|
},
|
||||||
warn(msg: string): void {
|
warn(msg: string): void {
|
||||||
if (shouldLog("warn")) console.warn(msg);
|
if (shouldLog("warn")) console.warn(`[${ts()}] [WARN] ${msg}`);
|
||||||
},
|
},
|
||||||
error(msg: string): void {
|
error(msg: string): void {
|
||||||
if (shouldLog("error")) console.error(msg);
|
if (shouldLog("error")) console.error(`[${ts()}] [ERROR] ${msg}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
|
||||||
|
|
||||||
|
export type PackageType = (typeof PACKAGE_TYPES)[number];
|
||||||
|
|
||||||
|
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
|
||||||
|
|
||||||
|
export function normalizePackageType(packageType?: string | null): PackageType {
|
||||||
|
if (packageType && PACKAGE_TYPE_SET.has(packageType)) {
|
||||||
|
return packageType as PackageType;
|
||||||
|
}
|
||||||
|
return "blister";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTubePackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "tube";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiquidContainerPackageType(packageType?: string | null): boolean {
|
||||||
|
return normalizePackageType(packageType) === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmountBasedPackageType(packageType?: string | null): boolean {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
|
||||||
|
const normalized = normalizePackageType(packageType);
|
||||||
|
if (normalized === "tube") return "units";
|
||||||
|
if (normalized === "liquid_container") return "ml";
|
||||||
|
return "pills";
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||||
|
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
|
||||||
|
|
||||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||||
export type Blister = { usage: number; every: number; start: string };
|
export type Blister = { usage: number; every: number; start: string };
|
||||||
@@ -13,10 +14,39 @@ export type Intake = {
|
|||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
start: string;
|
start: string;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
|
||||||
|
value === "ml" || value === "tsp" || value === "tbsp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize intake usage for stock math.
|
||||||
|
*
|
||||||
|
* Stock semantics:
|
||||||
|
* - tube: no automatic depletion (unknown per-application amount)
|
||||||
|
* - liquid_container/liquid forms: convert tsp/tbsp to ml
|
||||||
|
* - others: usage as-is
|
||||||
|
*/
|
||||||
|
export function normalizeIntakeUsageForStock(
|
||||||
|
intake: Pick<Intake, "usage" | "intakeUnit">,
|
||||||
|
medicationForm?: string | null,
|
||||||
|
packageType?: string | null
|
||||||
|
): number {
|
||||||
|
const usage = Number(intake.usage);
|
||||||
|
if (!Number.isFinite(usage) || usage <= 0) return 0;
|
||||||
|
if (isTubePackageType(packageType)) return 0;
|
||||||
|
|
||||||
|
const isLiquidStock = isLiquidContainerPackageType(packageType) || medicationForm === "liquid";
|
||||||
|
if (!isLiquidStock) return usage;
|
||||||
|
|
||||||
|
if (intake.intakeUnit === "tsp") return usage * 5;
|
||||||
|
if (intake.intakeUnit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Timezone utilities
|
// Timezone utilities
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -122,7 +152,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
|||||||
/** Calculate milliseconds until next check at the given reminder hour */
|
/** Calculate milliseconds until next check at the given reminder hour */
|
||||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||||
const next = getNextScheduledTime(reminderHour, tz);
|
const next = getNextScheduledTime(reminderHour, tz);
|
||||||
return next.getTime() - Date.now();
|
const msUntilNext = next.getTime() - Date.now();
|
||||||
|
if (msUntilNext <= 0) {
|
||||||
|
return msUntilNext + 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
return msUntilNext;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -195,6 +229,7 @@ export function parseIntakesJson(
|
|||||||
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(),
|
||||||
|
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
|
||||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||||
intakeRemindersEnabled:
|
intakeRemindersEnabled:
|
||||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||||
@@ -212,6 +247,7 @@ export function parseIntakesJson(
|
|||||||
usage: b.usage,
|
usage: b.usage,
|
||||||
every: b.every,
|
every: b.every,
|
||||||
start: b.start,
|
start: b.start,
|
||||||
|
intakeUnit: null,
|
||||||
takenBy: null, // Legacy format has no per-intake takenBy
|
takenBy: null, // Legacy format has no per-intake takenBy
|
||||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -483,6 +519,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 +542,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 +562,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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
+74
-30
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { expect, test as setup } from "@playwright/test";
|
import { expect, test as setup } from "@playwright/test";
|
||||||
import { TEST_USER } from "./fixtures";
|
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||||
|
|
||||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||||
|
|
||||||
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
|
|||||||
* 4. Log in via the UI.
|
* 4. Log in via the UI.
|
||||||
*/
|
*/
|
||||||
setup("authenticate", async ({ page }) => {
|
setup("authenticate", async ({ page }) => {
|
||||||
|
await applyVideoSafetyMode(page);
|
||||||
|
|
||||||
// Create .auth directory if it doesn't exist
|
// Create .auth directory if it doesn't exist
|
||||||
const authDir = path.dirname(authFile);
|
const authDir = path.dirname(authFile);
|
||||||
if (!fs.existsSync(authDir)) {
|
if (!fs.existsSync(authDir)) {
|
||||||
@@ -68,40 +70,82 @@ setup("authenticate", async ({ page }) => {
|
|||||||
// Wait for auth container
|
// Wait for auth container
|
||||||
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// ---- 3. Ensure the test user exists ----
|
// ---- 3. Query auth state to determine login method ----
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
await page.request
|
let formLoginEnabled = true;
|
||||||
.post(`${baseURL}/api/auth/register`, {
|
let oidcEnabled = false;
|
||||||
data: { username: TEST_USER.username, password: TEST_USER.password },
|
try {
|
||||||
})
|
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
|
||||||
.catch(() => {});
|
if (stateRes.ok()) {
|
||||||
|
const state = await stateRes.json();
|
||||||
// ---- 4. Log in via UI ----
|
formLoginEnabled = state.formLoginEnabled !== false;
|
||||||
const usernameField = page.locator("#username");
|
oidcEnabled = state.oidcEnabled === true;
|
||||||
const passwordField = page.locator("#password");
|
|
||||||
|
|
||||||
// Make sure we're on the login form (not register)
|
|
||||||
const isOnRegister = await page
|
|
||||||
.locator(".auth-subtitle")
|
|
||||||
.filter({ hasText: /Create Account/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (isOnRegister) {
|
|
||||||
const switchBtn = page.locator("button.auth-link-btn");
|
|
||||||
if (await switchBtn.isVisible().catch(() => false)) {
|
|
||||||
await switchBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: assume form login is available
|
||||||
}
|
}
|
||||||
|
|
||||||
await usernameField.clear();
|
// ---- 4. Ensure the test user exists (only if form login is available) ----
|
||||||
await usernameField.fill(TEST_USER.username);
|
if (formLoginEnabled) {
|
||||||
await passwordField.clear();
|
await page.request
|
||||||
await passwordField.fill(TEST_USER.password);
|
.post(`${baseURL}/api/auth/register`, {
|
||||||
|
data: { username: TEST_USER.username, password: TEST_USER.password },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Click the submit button (not the SSO button)
|
// ---- 5. Log in via the appropriate method ----
|
||||||
await page.locator('button.auth-submit[type="submit"]').click();
|
if (formLoginEnabled) {
|
||||||
|
// Form login path: username/password
|
||||||
|
const usernameField = page.locator("#username");
|
||||||
|
const passwordField = page.locator("#password");
|
||||||
|
|
||||||
|
// Make sure we're on the login form (not register)
|
||||||
|
const isOnRegister = await page
|
||||||
|
.locator(".auth-subtitle")
|
||||||
|
.filter({ hasText: /Create Account/i })
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (isOnRegister) {
|
||||||
|
const switchBtn = page.locator("button.auth-link-btn");
|
||||||
|
if (await switchBtn.isVisible().catch(() => false)) {
|
||||||
|
await switchBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await usernameField.clear();
|
||||||
|
await usernameField.fill(TEST_USER.username);
|
||||||
|
await passwordField.clear();
|
||||||
|
await passwordField.fill(TEST_USER.password);
|
||||||
|
|
||||||
|
// Click the submit button (not the SSO button)
|
||||||
|
await page.locator('button.auth-submit[type="submit"]').click();
|
||||||
|
} else if (oidcEnabled) {
|
||||||
|
// SSO-only path: click the SSO button and let the OIDC provider handle login.
|
||||||
|
// This requires the OIDC provider to be configured with test credentials
|
||||||
|
// (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars)
|
||||||
|
// or to auto-approve the test user.
|
||||||
|
await page.locator("button.sso-btn").click();
|
||||||
|
|
||||||
|
// Wait for OIDC redirect and callback — the provider may show its own login form
|
||||||
|
const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME;
|
||||||
|
const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD;
|
||||||
|
if (oidcUsername && oidcPassword) {
|
||||||
|
// Fill OIDC provider login form (generic selectors — override if needed)
|
||||||
|
await page.waitForURL(/.*/, { timeout: 15000 });
|
||||||
|
const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first();
|
||||||
|
const oidcPassField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) {
|
||||||
|
await oidcUserField.fill(oidcUsername);
|
||||||
|
await oidcPassField.fill(oidcPassword);
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("No login method available: form login and OIDC are both disabled");
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for successful auth — app header should appear
|
// Wait for successful auth — app header should appear
|
||||||
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { expect, type Page, test } from "@playwright/test";
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
async function isAuthEnabled(page: Page): Promise<boolean> {
|
interface AuthStateResponse {
|
||||||
|
authEnabled: boolean;
|
||||||
|
formLoginEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcProviderName: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthState(page: Page): Promise<AuthStateResponse | null> {
|
||||||
try {
|
try {
|
||||||
const response = await page.request.get("/api/auth/state");
|
const response = await page.request.get("/api/auth/state");
|
||||||
if (!response.ok()) return true;
|
if (!response.ok()) return null;
|
||||||
const state = await response.json();
|
return (await response.json()) as AuthStateResponse;
|
||||||
return state?.authEnabled !== false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isAuthEnabled(page: Page): Promise<boolean> {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
return state?.authEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication E2E Tests
|
* Authentication E2E Tests
|
||||||
*
|
*
|
||||||
@@ -110,4 +122,48 @@ test.describe("Authentication", () => {
|
|||||||
const newText = await subtitle.textContent();
|
const newText = await subtitle.textContent();
|
||||||
expect(newText).not.toBe(initialText);
|
expect(newText).not.toBe(initialText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should show SSO button when OIDC is enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled in this environment");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const ssoButton = page.locator("button.sso-btn");
|
||||||
|
await expect(ssoButton).toBeVisible();
|
||||||
|
await expect(ssoButton).toContainText(state.oidcProviderName || "SSO");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should hide form login when formLoginEnabled is false", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(state?.formLoginEnabled !== false, "Form login is enabled — cannot test hidden state");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Username/password fields should not be visible
|
||||||
|
await expect(page.locator("#username")).not.toBeVisible();
|
||||||
|
await expect(page.locator("#password")).not.toBeVisible();
|
||||||
|
|
||||||
|
// SSO button should be the only login method
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should show both login methods when OIDC and form login are enabled", async ({ page }) => {
|
||||||
|
const state = await getAuthState(page);
|
||||||
|
test.skip(!state?.authEnabled, "Auth is disabled in this environment");
|
||||||
|
test.skip(!state?.oidcEnabled, "OIDC is not enabled");
|
||||||
|
test.skip(!state?.formLoginEnabled, "Form login is not enabled");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Both login methods visible
|
||||||
|
await expect(page.locator("#username")).toBeVisible();
|
||||||
|
await expect(page.locator("#password")).toBeVisible();
|
||||||
|
await expect(page.locator("button.sso-btn")).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show medication overview table with medications", async ({ page }) => {
|
test("should show medication overview table with medications", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show status chips in overview table", async ({ page }) => {
|
test("should show status chips in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Each medication row should have a status chip
|
// Each medication row should have a status chip
|
||||||
@@ -88,7 +88,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should show stock information in overview", async ({ page }) => {
|
test("should show stock information in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
// The Ibuprofen row should show stock info (60 pills minus today's usage = 59)
|
||||||
@@ -202,7 +202,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
test("should open medication detail modal from overview table", async ({ page }) => {
|
test("should open medication detail modal from overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_1 }).first();
|
||||||
|
|||||||
+159
-28
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce visual flashing in recorded videos by forcing a dark first paint and
|
||||||
|
* disabling most animations/transitions in test mode.
|
||||||
|
*/
|
||||||
|
export async function applyVideoSafetyMode(page: Page): Promise<void> {
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "pw-video-safety-style";
|
||||||
|
style.textContent = `
|
||||||
|
html, body {
|
||||||
|
background: #111111 !important;
|
||||||
|
color-scheme: dark !important;
|
||||||
|
}
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.documentElement.appendChild(style);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended test fixture that automatically mocks /auth/me on every page
|
* Extended test fixture that automatically mocks /auth/me on every page
|
||||||
* using user data from the JWT in the stored auth file.
|
* using user data from the JWT in the stored auth file.
|
||||||
@@ -70,8 +93,9 @@ 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 applyVideoSafetyMode(page);
|
||||||
await setupAuthMeMock(page);
|
await setupAuthMeMock(page);
|
||||||
await use(page);
|
await use(page);
|
||||||
},
|
},
|
||||||
@@ -79,25 +103,43 @@ export const test = base.extend<{}>({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the app to be fully loaded past any loading/initializing screens.
|
* Wait for the app to be fully loaded past any loading/initializing screens.
|
||||||
* Includes a single retry with page reload to handle transient auth failures
|
* Retries up to 2 times with page reload to handle transient auth or
|
||||||
* (e.g. brief race between context setup and cookie application).
|
* rate-limit failures.
|
||||||
*/
|
*/
|
||||||
export async function waitForAppReady(page: Page): Promise<void> {
|
export async function waitForAppReady(page: Page): Promise<void> {
|
||||||
const hero = page.locator("header.hero");
|
const hero = page.locator("header.hero");
|
||||||
try {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
try {
|
||||||
} catch {
|
await expect(hero).toBeVisible({ timeout: 15000 });
|
||||||
// Auth might have failed transiently — reload and retry once
|
return;
|
||||||
await page.reload();
|
} catch {
|
||||||
await expect(hero).toBeVisible({ timeout: 15000 });
|
if (attempt === 2) throw new Error("App failed to become ready after 3 attempts");
|
||||||
|
// Check for rate-limit error displayed in UI
|
||||||
|
const rateLimited = await page
|
||||||
|
.locator("text=rate limit, text=429, text=too many")
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
if (rateLimited) {
|
||||||
|
// Wait longer before retrying if rate-limited
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
await page.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a page and wait for it to be ready.
|
* Navigate to a page and wait for it to be ready.
|
||||||
|
* Handles transient navigation failures with a single retry.
|
||||||
*/
|
*/
|
||||||
export async function navigateTo(page: Page, path: string): Promise<void> {
|
export async function navigateTo(page: Page, path: string): Promise<void> {
|
||||||
await page.goto(path);
|
const response = await page.goto(path);
|
||||||
|
if (response && response.status() === 429) {
|
||||||
|
// Rate-limited — wait and retry once
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
await page.goto(path);
|
||||||
|
}
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
}
|
}
|
||||||
@@ -135,7 +177,9 @@ export { expect };
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
function getAuthCookie(): string | null {
|
let cachedAuthCookie: string | null = null;
|
||||||
|
|
||||||
|
function readAuthCookieFromFile(): string | null {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const state = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||||
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
return state.cookies?.find((c: { name: string }) => c.name === "access_token")?.value ?? null;
|
||||||
@@ -144,6 +188,49 @@ function getAuthCookie(): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCookieValue(setCookieHeaders: string[], name: string): string | null {
|
||||||
|
for (const header of setCookieHeaders) {
|
||||||
|
const [pair] = header.split(";");
|
||||||
|
if (!pair) continue;
|
||||||
|
const [cookieName, ...valueParts] = pair.split("=");
|
||||||
|
if (cookieName?.trim() !== name) continue;
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAuthCookieViaLogin(): Promise<string | null> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: TEST_USER.username,
|
||||||
|
password: TEST_USER.password,
|
||||||
|
rememberMe: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const getSetCookie = (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
const setCookieHeaders = typeof getSetCookie === "function" ? getSetCookie.call(res.headers) : [];
|
||||||
|
const fallback = res.headers.get("set-cookie");
|
||||||
|
if (fallback) setCookieHeaders.push(fallback);
|
||||||
|
|
||||||
|
const accessToken = extractCookieValue(setCookieHeaders, "access_token");
|
||||||
|
if (accessToken) {
|
||||||
|
cachedAuthCookie = accessToken;
|
||||||
|
}
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthCookie(): string | null {
|
||||||
|
if (cachedAuthCookie) return cachedAuthCookie;
|
||||||
|
cachedAuthCookie = readAuthCookieFromFile();
|
||||||
|
return cachedAuthCookie;
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed medication response (subset of fields we care about) */
|
/** Typed medication response (subset of fields we care about) */
|
||||||
export interface TestMedication {
|
export interface TestMedication {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -172,12 +259,14 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string[];
|
takenBy?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
|
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
looseTablets?: number;
|
looseTablets?: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
|
packageAmountValue?: number;
|
||||||
intakeRemindersEnabled?: boolean;
|
intakeRemindersEnabled?: boolean;
|
||||||
intakes?: {
|
intakes?: {
|
||||||
usage: number;
|
usage: number;
|
||||||
@@ -187,16 +276,30 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string | null;
|
takenBy?: string | null;
|
||||||
}[];
|
}[];
|
||||||
}): Promise<TestMedication> {
|
}): Promise<TestMedication> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
const isBottle = data.packageType === "bottle";
|
const packageType = data.packageType ?? "blister";
|
||||||
|
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||||
|
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||||
|
if (packageType === "tube") {
|
||||||
|
defaultMedicationForm = "topical";
|
||||||
|
} else if (packageType === "liquid_container") {
|
||||||
|
defaultMedicationForm = "liquid";
|
||||||
|
}
|
||||||
|
const medicationForm = data.medicationForm ?? defaultMedicationForm;
|
||||||
|
const packageAmountValue =
|
||||||
|
data.packageAmountValue ??
|
||||||
|
(packageType === "tube" || packageType === "liquid_container" ? Math.max(1, data.totalPills ?? 30) : 0);
|
||||||
const body = {
|
const body = {
|
||||||
packageType: isBottle ? "bottle" : "blister",
|
packageType,
|
||||||
packCount: isBottle ? 1 : (data.packCount ?? 1),
|
medicationForm,
|
||||||
blistersPerPack: isBottle ? 1 : (data.blistersPerPack ?? 1),
|
packCount: packageType === "tube" ? 1 : (data.packCount ?? 1),
|
||||||
pillsPerBlister: isBottle ? 1 : (data.pillsPerBlister ?? 10),
|
blistersPerPack: isAmountBased ? 1 : (data.blistersPerPack ?? 1),
|
||||||
// For bottles: looseTablets IS the current stock. Default to totalPills if not specified.
|
pillsPerBlister: isAmountBased ? 1 : (data.pillsPerBlister ?? 10),
|
||||||
looseTablets: isBottle ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
// Amount-based packages use looseTablets as current stock.
|
||||||
totalPills: isBottle ? (data.totalPills ?? null) : null,
|
looseTablets: isAmountBased ? (data.looseTablets ?? data.totalPills ?? 0) : (data.looseTablets ?? 0),
|
||||||
|
totalPills: isAmountBased ? (data.totalPills ?? null) : null,
|
||||||
|
packageAmountValue,
|
||||||
|
packageAmountUnit: packageType === "tube" ? "g" : "ml",
|
||||||
intakes: [
|
intakes: [
|
||||||
{
|
{
|
||||||
usage: 1,
|
usage: 1,
|
||||||
@@ -219,6 +322,10 @@ export async function createMedicationViaAPI(data: {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
// Rate limited — exponential backoff: 3s, 6s, 9s, 12s, 15s
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
@@ -235,13 +342,25 @@ export async function createMedicationViaAPI(data: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a medication via the backend API.
|
* Delete a medication via the backend API.
|
||||||
|
* Includes retry for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
await fetch(`${API_BASE}/api/medications/${id}`, {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
method: "DELETE",
|
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
method: "DELETE",
|
||||||
});
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,11 +368,15 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
|
|||||||
* Includes retry logic for rate-limited responses.
|
* Includes retry logic for rate-limited responses.
|
||||||
*/
|
*/
|
||||||
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/medications`, {
|
const res = await fetch(`${API_BASE}/api/medications`, {
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
@@ -266,6 +389,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: token ? { Cookie: `access_token=${token}` } : {},
|
headers: token ? { Cookie: `access_token=${token}` } : {},
|
||||||
});
|
});
|
||||||
|
if (delRes.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (delRes.status === 429) {
|
if (delRes.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
continue;
|
continue;
|
||||||
@@ -282,7 +409,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
* Requires a medication with takenBy to exist first.
|
* Requires a medication with takenBy to exist first.
|
||||||
*/
|
*/
|
||||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
||||||
const token = getAuthCookie();
|
let token = getAuthCookie();
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const res = await fetch(`${API_BASE}/api/share`, {
|
const res = await fetch(`${API_BASE}/api/share`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -292,6 +419,10 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
body: JSON.stringify({ takenBy, scheduleDays }),
|
||||||
});
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
if (token) continue;
|
||||||
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
|||||||
opts: {
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
genericName?: string;
|
genericName?: string;
|
||||||
packageType?: "blister" | "bottle";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||||
packs?: string;
|
packs?: string;
|
||||||
blistersPerPack?: string;
|
blistersPerPack?: string;
|
||||||
pillsPerBlister?: string;
|
pillsPerBlister?: string;
|
||||||
@@ -56,6 +56,18 @@ async function fillAndSaveMedication(
|
|||||||
if (opts.totalCapacity)
|
if (opts.totalCapacity)
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(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);
|
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||||
|
} else if (opts.packageType === "tube") {
|
||||||
|
await packageTypeSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
|
} else if (opts.packageType === "liquid_container") {
|
||||||
|
await packageTypeSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
if (opts.totalCapacity) {
|
||||||
|
await form.getByLabel(/(Package amount|form\.packageAmount)/i).fill(opts.totalCapacity);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await packageTypeSelect.selectOption("blister");
|
await packageTypeSelect.selectOption("blister");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
@@ -83,7 +95,11 @@ async function fillAndSaveMedication(
|
|||||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = form.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
await row
|
||||||
|
.getByLabel(
|
||||||
|
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
||||||
|
)
|
||||||
|
.fill(intakes[i].usage);
|
||||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +210,26 @@ test.describe("Medication CRUD", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create a tube medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Tube Cream",
|
||||||
|
packageType: "tube",
|
||||||
|
totalCapacity: "50",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create a liquid-container medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Liquid Syrup",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
totalCapacity: "120",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("should create medication with notes and expiry date", async ({ page }) => {
|
test("should create medication with notes and expiry date", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// 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\)|form\.blisters\.usage)/i);
|
const usageField = intakeRow.getByLabel(/(Usage|form\.blisters\.usage)/i);
|
||||||
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
|
|
||||||
await usageField.fill("2");
|
await usageField.fill("2");
|
||||||
@@ -247,7 +247,7 @@ 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\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
await expect(savedRow.getByLabel(/(Usage|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||||
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// 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\)|form\.blisters\.usage)/i).fill("0.5");
|
await secondRow.getByLabel(/(Usage|form\.blisters\.usage)/i).fill("0.5");
|
||||||
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/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");
|
||||||
@@ -329,7 +329,7 @@ test.describe("Medication Editing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should change package type between blister and bottle", async ({ page }) => {
|
test("should change package type across all supported profiles", async ({ page }) => {
|
||||||
createdMeds.push(
|
createdMeds.push(
|
||||||
await createMedicationViaAPI({
|
await createMedicationViaAPI({
|
||||||
name: "PackType Change Med",
|
name: "PackType Change Med",
|
||||||
@@ -357,15 +357,24 @@ test.describe("Medication Editing", () => {
|
|||||||
await packageSelect.selectOption("bottle");
|
await packageSelect.selectOption("bottle");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Fill bottle-specific fields
|
// Switch to tube
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
await packageSelect.selectOption("tube");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
|
// Switch to liquid container and persist this final state
|
||||||
|
await packageSelect.selectOption("liquid_container");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
// Verify it's still a bottle after reload
|
// Verify final package type persisted
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
await expect(page.locator("select.package-type-select")).toHaveValue("bottle");
|
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medication Lifecycle Integration Tests
|
||||||
|
*
|
||||||
|
* End-to-end workflows that verify changes propagate across pages:
|
||||||
|
* create → verify on medications → check in planner → check in schedule → edit → delete
|
||||||
|
*/
|
||||||
|
test.describe("Medication lifecycle", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 90000 });
|
||||||
|
|
||||||
|
const MED_NAME = "Lifecycle TestMed";
|
||||||
|
const MED_EDITED = "Lifecycle Edited";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create medication via API and verify it appears on all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Step 1: Create medication
|
||||||
|
const created = await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
expect(created.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Step 2: Verify on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Step 3: Verify in planner
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(page.locator(".table").getByText(MED_NAME)).toBeVisible();
|
||||||
|
|
||||||
|
// Step 4: Verify in schedule
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit medication name via UI and verify update propagates", async ({ page }) => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create a fresh medication for this test
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Open edit view from medication row actions
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: MED_NAME });
|
||||||
|
await expect(medRow.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
await medRow.first().locator("button.info").click();
|
||||||
|
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the name
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
const nameInput = form.getByLabel(/(Commercial Name|Name|form\.name)/i).first();
|
||||||
|
await nameInput.fill(MED_EDITED);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const submitButton = form.locator('button[type="submit"]').first();
|
||||||
|
await expect(submitButton).toBeEnabled({ timeout: 5000 });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for modal to close or save to complete
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Verify edited name appears on medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_EDITED).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
// Old name should no longer appear
|
||||||
|
await expect(page.locator(".med-row").filter({ hasText: MED_NAME })).toHaveCount(0, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete medication via API and verify it disappears from all pages", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create and then delete
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 5,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify it exists first
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Delete via API
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
// Verify gone from medications page
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await expect(page.getByText(MED_NAME)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Verify planner shows no results for this med
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
// Either no table or table without the medication name
|
||||||
|
const table = page.locator(".table");
|
||||||
|
const tableVisible = await table.isVisible().catch(() => false);
|
||||||
|
if (tableVisible) {
|
||||||
|
await expect(table.getByText(MED_NAME)).not.toBeVisible({ timeout: 3000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medication with multiple intakes shows all schedule entries", async ({ page }) => {
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const todayEvening = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(20, 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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: "MultiIntake Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false },
|
||||||
|
{ usage: 2, every: 1, start: todayEvening, intakeRemindersEnabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify schedule shows this medication
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
await expect(page.getByText("MultiIntake Med").first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The medication should appear at least twice (morning + evening)
|
||||||
|
const medEntries = page.getByText("MultiIntake Med");
|
||||||
|
expect(await medEntries.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,25 +87,17 @@ test.describe("Medications Page", () => {
|
|||||||
expect(hasPacks || hasTotal).toBeTruthy();
|
expect(hasPacks || hasTotal).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
test("should expose all supported package type options", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
const form = visibleMedForm(page);
|
const form = visibleMedForm(page);
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
const packageSelect = form.locator("select.package-type-select");
|
||||||
|
await expect(packageSelect).toBeVisible();
|
||||||
|
|
||||||
// Find the package type radio buttons or selector
|
const optionValues = await packageSelect
|
||||||
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
.locator("option")
|
||||||
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
.evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
|
||||||
|
|
||||||
if (await blisterOption.isVisible().catch(() => false)) {
|
expect(optionValues).toEqual(expect.arrayContaining(["blister", "bottle", "tube", "liquid_container"]));
|
||||||
// Switch to bottle
|
|
||||||
await bottleOption.click();
|
|
||||||
// Bottle-specific fields should appear
|
|
||||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Switch back to blister
|
|
||||||
await blisterOption.click();
|
|
||||||
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 }) => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, expect, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performance Tests
|
||||||
|
*
|
||||||
|
* Verify the schedule timeline and planner render within acceptable
|
||||||
|
* time limits when many medications exist.
|
||||||
|
*/
|
||||||
|
test.describe("Performance with many medications", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 120000 });
|
||||||
|
|
||||||
|
const MED_COUNT = 20;
|
||||||
|
const MED_PREFIX = "PerfTest Med";
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
|
||||||
|
const todayMorning = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
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())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create medications sequentially (API rate limits prevent parallel)
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: `${MED_PREFIX} ${i}`,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schedule page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/schedule");
|
||||||
|
|
||||||
|
// Wait for schedule entries to render
|
||||||
|
const scheduleEntries = page.locator(".schedule-entry, .timeline-entry, .card");
|
||||||
|
await expect(scheduleEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify all medications appear
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goal: render under 10 seconds
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("medications page renders within 10 seconds with 20 medications", async ({ page }) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
// Wait for medication cards to render
|
||||||
|
const medEntries = page.locator(".medication-card, .card, .table-row");
|
||||||
|
await expect(medEntries.first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const renderTime = Date.now() - start;
|
||||||
|
|
||||||
|
// Verify count — all 20 should be visible
|
||||||
|
for (let i = 1; i <= MED_COUNT; i++) {
|
||||||
|
await expect(page.getByText(`${MED_PREFIX} ${i}`).first()).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderTime).toBeLessThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planner calculates within 15 seconds with 20 medications", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.locator('form.planner button[type="submit"]').click();
|
||||||
|
await expect(page.locator(".table")).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const calcTime = Date.now() - start;
|
||||||
|
|
||||||
|
// All medications should appear in the results
|
||||||
|
const rows = page.locator(".table .table-row");
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(MED_COUNT);
|
||||||
|
|
||||||
|
// Goal: calculate and render under 15 seconds
|
||||||
|
expect(calcTime).toBeLessThan(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ test.describe("Planner with medications", () => {
|
|||||||
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
expect(await statusChips.count()).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show usage data in results rows", async ({ page }) => {
|
test("should show correct usage values in results rows", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|
||||||
@@ -116,10 +116,15 @@ test.describe("Planner with medications", () => {
|
|||||||
const rows = resultsTable.locator(".table-row");
|
const rows = resultsTable.locator(".table-row");
|
||||||
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
expect(await rows.count()).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
const firstRowText = await rows.first().textContent();
|
// Each medication has usage=1, every=1 → plannerUsage should reflect the period
|
||||||
expect(firstRowText).toBeTruthy();
|
// Verify the usage column contains a numeric <strong> value and "pill(s)"
|
||||||
// Check for "pill" (matches both "pill" and "pills")
|
for (const row of await rows.all()) {
|
||||||
expect(firstRowText!.toLowerCase()).toContain("pill");
|
const usageCell = row.locator("[data-label]").nth(1); // Usage is 2nd column
|
||||||
|
const usageStrong = usageCell.locator("strong");
|
||||||
|
await expect(usageStrong).toBeVisible();
|
||||||
|
const usageText = await usageStrong.textContent();
|
||||||
|
expect(Number(usageText)).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
test("should show danger status for low-stock medication over 90 days", async ({ page }) => {
|
||||||
@@ -139,9 +144,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Low-stock med (3 pills) should have a danger chip over 90 days
|
// Low-stock med (3 pills, usage 1/day, 90 days) should have danger status
|
||||||
const dangerChips = resultsTable.locator(".status-chip.danger");
|
const dangerChips = resultsTable.locator(".status-chip.danger");
|
||||||
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
expect(await dangerChips.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the low-stock med row and verify its usage value ~90 pills
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowUsage = await lowStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(lowUsage)).toBeGreaterThanOrEqual(85); // ~90 pills needed
|
||||||
|
expect(Number(lowUsage)).toBeLessThanOrEqual(95);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
test("should show Enough status for well-stocked medication over 7 days", async ({ page }) => {
|
||||||
@@ -161,9 +173,16 @@ test.describe("Planner with medications", () => {
|
|||||||
const resultsTable = page.locator(".table");
|
const resultsTable = page.locator(".table");
|
||||||
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// With 60 pills and 7-day range, high-stock should be "Enough"
|
// High-stock med (60 pills, usage 1/day, 7 days → needs ~7, has 60) should be "Enough"
|
||||||
const successChips = resultsTable.locator(".status-chip.success");
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
expect(await successChips.count()).toBeGreaterThanOrEqual(1);
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStatus = highStockRow.locator(".status-chip.success");
|
||||||
|
await expect(highStatus).toBeVisible();
|
||||||
|
|
||||||
|
// Verify usage is ~7 pills for the 7-day range
|
||||||
|
const highUsage = await highStockRow.locator("[data-label] strong").first().textContent();
|
||||||
|
expect(Number(highUsage)).toBeGreaterThanOrEqual(5);
|
||||||
|
expect(Number(highUsage)).toBeLessThanOrEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show table header with correct columns", async ({ page }) => {
|
test("should show table header with correct columns", async ({ page }) => {
|
||||||
@@ -180,6 +199,28 @@ test.describe("Planner with medications", () => {
|
|||||||
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
await expect(tableHead.getByText(/Status/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should display available stock for each medication", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/planner");
|
||||||
|
await calculatePlanner(page);
|
||||||
|
|
||||||
|
const resultsTable = page.locator(".table");
|
||||||
|
await expect(resultsTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// High-stock med should show a blister + loose-pill stock breakdown
|
||||||
|
const highStockRow = resultsTable.locator(".table-row", { hasText: MED_HIGH });
|
||||||
|
await expect(highStockRow).toBeVisible();
|
||||||
|
const highStockText = await highStockRow.textContent();
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(blisters|Blister)/i);
|
||||||
|
expect(highStockText).toMatch(/\d+\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
|
||||||
|
// Low-stock med: 1 pack × 1 blister × 3 pills = 3 pills = 0 full blisters + 3 loose
|
||||||
|
const lowStockRow = resultsTable.locator(".table-row", { hasText: MED_LOW });
|
||||||
|
await expect(lowStockRow).toBeVisible();
|
||||||
|
const lowStockText = await lowStockRow.textContent();
|
||||||
|
// Should show 3 loose pills
|
||||||
|
expect(lowStockText).toMatch(/3\s*(pill|pills|Tablette|Tabletten)/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("should reset form and clear results", async ({ page }) => {
|
test("should reset form and clear results", async ({ page }) => {
|
||||||
await navigateTo(page, "/planner");
|
await navigateTo(page, "/planner");
|
||||||
await calculatePlanner(page);
|
await calculatePlanner(page);
|
||||||
|
|||||||
@@ -224,15 +224,4 @@ test.describe("Schedule with medications", () => {
|
|||||||
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
expect(await takeButtons.count()).toBeGreaterThanOrEqual(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show medication names in timeline rows", async ({ page }) => {
|
|
||||||
await navigateTo(page, "/dashboard");
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
const medNames = todayBlock.locator(".med-name");
|
|
||||||
expect(await medNames.count()).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Overview table has class .table.table-7
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
const overviewTable = page.locator(".table.table-7");
|
|
||||||
await expect(overviewTable).toBeVisible();
|
await expect(overviewTable).toBeVisible();
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
test("should show taken-by badges on dashboard overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's medication should show "Alice" badge
|
// Alice's medication should show "Alice" badge
|
||||||
@@ -253,7 +253,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
test("should show notes icon on dashboard for medication with notes", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Alice's med has notes — should show the 📝 icon
|
// Alice's med has notes — should show the 📝 icon
|
||||||
@@ -265,7 +265,7 @@ test.describe("Share Schedule", () => {
|
|||||||
test("should show notes in medication detail modal", async ({ page }) => {
|
test("should show notes in medication detail modal", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on Alice's med to open detail modal
|
// Click on Alice's med to open detail modal
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show all medications in overview table", async ({ page }) => {
|
test("should show all medications in overview table", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// All 5 medications should appear
|
// All 5 medications should appear
|
||||||
@@ -139,7 +139,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
test("should show High status chip for well-stocked medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock med row should have a .status-chip.high
|
// High stock med row should have a .status-chip.high
|
||||||
@@ -151,7 +151,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
test("should show Normal status chip for moderate stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
const normalRow = overviewTable.locator(".table-row").filter({ hasText: MED_NORMAL });
|
||||||
@@ -162,7 +162,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
test("should show Warning status chip for low stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
const lowRow = overviewTable.locator(".table-row").filter({ hasText: MED_LOW });
|
||||||
@@ -173,7 +173,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
test("should show Danger status chip for critical stock medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
const criticalRow = overviewTable.locator(".table-row").filter({ hasText: MED_CRITICAL });
|
||||||
@@ -184,7 +184,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
test("should show Danger status chip for depleted medication", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
||||||
@@ -195,7 +195,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
test("should show days-left and runs-out date in overview", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock should show many days (around 299)
|
// High stock should show many days (around 299)
|
||||||
@@ -227,7 +227,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should color-code stock values depending on status", async ({ page }) => {
|
test("should color-code stock values depending on status", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock row should have success-text class on stock cells
|
// High stock row should have success-text class on stock cells
|
||||||
@@ -255,7 +255,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should open medication detail modal showing stock info", async ({ page }) => {
|
test("should open medication detail modal showing stock info", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the critical stock medication row
|
// Click on the critical stock medication row
|
||||||
@@ -278,7 +278,7 @@ test.describe("Stock Status Levels", () => {
|
|||||||
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
test("should show generic name in overview for medications that have one", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
// Click on the normal stock med (has generic name "Ibuprofen 400mg")
|
||||||
|
|||||||
@@ -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(".dashboard-overview-section .table").first();
|
||||||
|
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>
|
||||||
|
|||||||
@@ -4,21 +4,30 @@
|
|||||||
# Translates LOG_LEVEL into nginx access log control before
|
# Translates LOG_LEVEL into nginx access log control before
|
||||||
# delegating to the standard nginx-unprivileged entrypoint.
|
# delegating to the standard nginx-unprivileged entrypoint.
|
||||||
#
|
#
|
||||||
# LOG_LEVEL=debug|info → access logs enabled (default)
|
# LOG_LEVEL=debug → all access logs enabled (including polling)
|
||||||
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
# LOG_LEVEL=info → access logs enabled, polling endpoints suppressed (default)
|
||||||
|
# LOG_LEVEL=warn|error|fatal|silent → all access logs suppressed
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Normalize: lowercase + trim whitespace
|
# Normalize: lowercase + trim whitespace
|
||||||
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
|
||||||
case "$level" in
|
case "$level" in
|
||||||
|
debug)
|
||||||
|
export NGINX_ACCESS_LOG="/dev/stdout timed"
|
||||||
|
export NGINX_POLLING_LOG="/dev/stdout timed"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log on (all requests)"
|
||||||
|
;;
|
||||||
warn|error|fatal|silent)
|
warn|error|fatal|silent)
|
||||||
export NGINX_ACCESS_LOG="off"
|
export NGINX_ACCESS_LOG="off"
|
||||||
|
export NGINX_POLLING_LOG="off"
|
||||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
export NGINX_ACCESS_LOG="/dev/stdout"
|
# info (default): log everything except high-frequency polling endpoints
|
||||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
|
export NGINX_ACCESS_LOG="/dev/stdout timed"
|
||||||
|
export NGINX_POLLING_LOG="off"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log on (polling suppressed)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# Must be defined at http-level (outside server block)
|
||||||
|
log_format timed '$time_iso8601 $status $request_method $request_uri ($request_time s)';
|
||||||
|
|
||||||
server {
|
server {
|
||||||
# Port 8080 for unprivileged nginx (non-root)
|
# Port 8080 for unprivileged nginx (non-root)
|
||||||
listen 8080;
|
listen 8080;
|
||||||
@@ -14,6 +17,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;
|
||||||
@@ -22,6 +27,52 @@ server {
|
|||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# High-frequency polling endpoints — suppress access logs at info level
|
||||||
|
# (visible at debug level via NGINX_POLLING_LOG)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
location = /api/doses/taken {
|
||||||
|
access_log ${NGINX_POLLING_LOG};
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $backend_upstream ${BACKEND_URL};
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$backend_upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass_header Set-Cookie;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/api/share/[^/]+/doses$ {
|
||||||
|
access_log ${NGINX_POLLING_LOG};
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $backend_upstream ${BACKEND_URL};
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$backend_upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass_header Set-Cookie;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/health {
|
||||||
|
access_log ${NGINX_POLLING_LOG};
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $backend_upstream ${BACKEND_URL};
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$backend_upstream;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass_header Set-Cookie;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
# Use variable for runtime DNS resolution (nginx resolves at startup by default)
|
# Use variable for runtime DNS resolution (nginx resolves at startup by default)
|
||||||
# Docker embedded DNS (127.0.0.11) with 10s cache
|
# Docker embedded DNS (127.0.0.11) with 10s cache
|
||||||
|
|||||||
Generated
+231
-180
@@ -1,30 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.18.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.18.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.10",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"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.4.1",
|
"@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/react": "^18.3.4",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@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",
|
||||||
@@ -405,9 +406,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz",
|
||||||
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
|
"integrity": "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -421,20 +422,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.4.1",
|
"@biomejs/cli-darwin-arm64": "2.4.4",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.1",
|
"@biomejs/cli-darwin-x64": "2.4.4",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.1",
|
"@biomejs/cli-linux-arm64": "2.4.4",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.1",
|
"@biomejs/cli-linux-arm64-musl": "2.4.4",
|
||||||
"@biomejs/cli-linux-x64": "2.4.1",
|
"@biomejs/cli-linux-x64": "2.4.4",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.1",
|
"@biomejs/cli-linux-x64-musl": "2.4.4",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.1",
|
"@biomejs/cli-win32-arm64": "2.4.4",
|
||||||
"@biomejs/cli-win32-x64": "2.4.1"
|
"@biomejs/cli-win32-x64": "2.4.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
|
"integrity": "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -449,9 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
|
"integrity": "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -466,9 +467,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
|
"integrity": "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -483,9 +484,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.4.tgz",
|
||||||
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
|
"integrity": "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -500,9 +501,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
|
"integrity": "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -517,9 +518,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.4.tgz",
|
||||||
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
|
"integrity": "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -534,9 +535,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.4.tgz",
|
||||||
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
|
"integrity": "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -551,9 +552,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.4.tgz",
|
||||||
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
|
"integrity": "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1246,9 +1247,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1260,9 +1261,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1274,9 +1275,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1288,9 +1289,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1302,9 +1303,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1316,9 +1317,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1330,9 +1331,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1344,9 +1345,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1358,9 +1359,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1372,9 +1373,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1386,9 +1387,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1400,9 +1415,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1414,9 +1443,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1428,9 +1457,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1442,9 +1471,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1456,9 +1485,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1470,9 +1499,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1483,10 +1512,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1498,9 +1541,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1512,9 +1555,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1526,9 +1569,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1540,9 +1583,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1735,32 +1778,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/node": {
|
||||||
"version": "15.7.15",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
},
|
"dependencies": {
|
||||||
"node_modules/@types/react": {
|
"undici-types": "~7.18.0"
|
||||||
"version": "18.3.27",
|
}
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
},
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-router": {
|
"node_modules/@types/react-router": {
|
||||||
@@ -2449,9 +2494,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.8.10",
|
"version": "25.8.13",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
|
||||||
"integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
|
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2640,9 +2685,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.574.0",
|
"version": "0.575.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||||
"integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==",
|
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -2914,28 +2959,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"loose-envify": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.27.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
@@ -2983,9 +3024,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",
|
||||||
@@ -3005,12 +3046,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"
|
||||||
@@ -3045,9 +3086,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.53.5",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3061,28 +3102,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.53.5",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.53.5",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.53.5",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.53.5",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.53.5",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.53.5",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.5",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.53.5",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.53.5",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.53.5",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.5",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.5",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.53.5",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.53.5",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.53.5",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.53.5",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.53.5",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.53.5",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.53.5",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.53.5",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.53.5",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3100,9 +3144,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
@@ -3302,6 +3346,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",
|
||||||
|
|||||||
+12
-9
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.0",
|
"version": "1.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||||
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||||
|
"test:e2e:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e",
|
||||||
|
"test:e2e:all:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:all",
|
||||||
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||||
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
"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: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",
|
||||||
@@ -25,23 +27,24 @@
|
|||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.10",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.4",
|
||||||
"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.4.1",
|
"@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/react": "^18.3.4",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
||||||
: {};
|
: {};
|
||||||
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
||||||
|
// Default to single-worker execution to keep API-seeded E2E suites deterministic.
|
||||||
|
// Still allow explicit local overrides via PLAYWRIGHT_WORKERS.
|
||||||
|
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 1;
|
||||||
|
|
||||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
{
|
{
|
||||||
@@ -17,13 +21,13 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
retries: 1,
|
retries: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "chromium-data",
|
name: "chromium-data",
|
||||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testMatch: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
},
|
},
|
||||||
@@ -40,7 +44,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Firefox"],
|
...devices["Desktop Firefox"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,7 +52,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
use: {
|
use: {
|
||||||
...devices["Desktop Safari"],
|
...devices["Desktop Safari"],
|
||||||
},
|
},
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
testIgnore: /.*-(?:data|crud|edit|status|schedule|lifecycle)\.spec\.ts|performance\.spec\.ts/,
|
||||||
dependencies: ["setup"],
|
dependencies: ["setup"],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -64,7 +68,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!env.CI,
|
forbidOnly: !!env.CI,
|
||||||
retries: env.CI ? 2 : 0,
|
retries: env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers,
|
||||||
reporter: env.CI
|
reporter: env.CI
|
||||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||||
|
|||||||
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 |
+170
-83
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useNavigate } 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
|
||||||
@@ -36,13 +37,29 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialAuthTheme(): "light" | "dark" {
|
||||||
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored === "light" || stored === "dark") {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored === "system") {
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const { user, authState, loading, authError } = useAuth();
|
const { user, authState, loading, authError } = useAuth();
|
||||||
|
const authTheme = getInitialAuthTheme();
|
||||||
|
|
||||||
// Show loading while checking auth state
|
// Show loading while checking auth state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -54,7 +71,7 @@ function AppRouter() {
|
|||||||
// Show error if we couldn't connect to the server
|
// Show error if we couldn't connect to the server
|
||||||
if (authError) {
|
if (authError) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||||
@@ -76,7 +93,7 @@ function AppRouter() {
|
|||||||
// If auth state is null (shouldn't happen after loading, but be safe)
|
// If auth state is null (shouldn't happen after loading, but be safe)
|
||||||
if (!authState) {
|
if (!authState) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container" data-theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
<p>Initializing...</p>
|
<p>Initializing...</p>
|
||||||
@@ -113,14 +130,13 @@ function AppRouter() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const navigate = useNavigate();
|
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,
|
||||||
@@ -190,59 +206,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 = () => {
|
||||||
@@ -331,21 +312,87 @@ 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");
|
if (e.defaultPrevented) return;
|
||||||
document.body.classList.add("modal-open");
|
|
||||||
} else {
|
if (scheduleLightboxImage) {
|
||||||
document.documentElement.classList.remove("modal-open");
|
closeScheduleLightbox();
|
||||||
document.body.classList.remove("modal-open");
|
return;
|
||||||
}
|
}
|
||||||
return () => {
|
if (showImageLightbox) {
|
||||||
document.documentElement.classList.remove("modal-open");
|
closeImageLightbox();
|
||||||
document.body.classList.remove("modal-open");
|
return;
|
||||||
|
}
|
||||||
|
if (showEditStockModal) {
|
||||||
|
closeEditStockModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showRefillModal) {
|
||||||
|
closeRefillModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showShareDialog) {
|
||||||
|
closeShareDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showAbout) {
|
||||||
|
closeAbout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showProfile) {
|
||||||
|
closeProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedUser) {
|
||||||
|
closeUserFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedMed) {
|
||||||
|
closeMedDetail();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog]);
|
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [
|
||||||
|
showImageLightbox,
|
||||||
|
scheduleLightboxImage,
|
||||||
|
showEditStockModal,
|
||||||
|
showRefillModal,
|
||||||
|
showShareDialog,
|
||||||
|
showAbout,
|
||||||
|
showProfile,
|
||||||
|
selectedUser,
|
||||||
|
selectedMed,
|
||||||
|
closeImageLightbox,
|
||||||
|
closeScheduleLightbox,
|
||||||
|
closeEditStockModal,
|
||||||
|
closeRefillModal,
|
||||||
|
closeShareDialog,
|
||||||
|
closeAbout,
|
||||||
|
closeProfile,
|
||||||
|
closeUserFilter,
|
||||||
|
closeMedDetail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prevent background scroll when any modal is open
|
||||||
|
useScrollLock(
|
||||||
|
!!(
|
||||||
|
selectedMed ||
|
||||||
|
selectedUser ||
|
||||||
|
showProfile ||
|
||||||
|
showAbout ||
|
||||||
|
showShareDialog ||
|
||||||
|
showRefillModal ||
|
||||||
|
showEditStockModal ||
|
||||||
|
showImageLightbox ||
|
||||||
|
scheduleLightboxImage
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Update selectedMed when meds change (e.g., after refill)
|
// Update selectedMed when meds change (e.g., after refill)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -374,9 +421,57 @@ function AppContent() {
|
|||||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeTransitionMaskActive) return;
|
||||||
|
if (location.pathname !== "/medications") return;
|
||||||
|
|
||||||
|
const hasEditMedIdParam = new URLSearchParams(location.search).has("editMedId");
|
||||||
|
if (hasEditMedIdParam) return;
|
||||||
|
|
||||||
|
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||||
|
const timer = window.setTimeout(() => setRouteTransitionMaskActive(false), remaining);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [location.pathname, location.search, routeTransitionMaskActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEditTransitionReady = () => {
|
||||||
|
if (!routeTransitionMaskActive) return;
|
||||||
|
const remaining = Math.max(0, routeTransitionMinEndRef.current - performance.now());
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setRouteTransitionMaskActive(false);
|
||||||
|
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||||
|
routeTransitionFallbackTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, remaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("medassist:edit-transition-ready", handleEditTransitionReady);
|
||||||
|
};
|
||||||
|
}, [routeTransitionMaskActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (routeTransitionFallbackTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(routeTransitionFallbackTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOpenMedicationEdit = () => {
|
const handleOpenMedicationEdit = () => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
const medId = selectedMed.id;
|
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);
|
setShowImageLightbox(false);
|
||||||
setShowRefillModal(false);
|
setShowRefillModal(false);
|
||||||
setShowEditStockModal(false);
|
setShowEditStockModal(false);
|
||||||
@@ -389,25 +484,15 @@ function AppContent() {
|
|||||||
openEditStockModal(selectedMed, coverage);
|
openEditStockModal(selectedMed, coverage);
|
||||||
};
|
};
|
||||||
|
|
||||||
function openProfile() {
|
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">
|
||||||
@@ -509,6 +594,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,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);
|
||||||
|
|
||||||
|
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -55,20 +57,22 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content about-modal"
|
className="modal-content about-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(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>
|
||||||
|
|||||||
@@ -73,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";
|
||||||
@@ -16,7 +20,7 @@ export interface User {
|
|||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
localAuthEnabled: boolean;
|
formLoginEnabled: boolean;
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
@@ -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.debug("[Auth] Session refresh unavailable, 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,50 @@ 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",
|
{
|
||||||
credentials: "include",
|
method: "POST",
|
||||||
});
|
credentials: "include",
|
||||||
|
},
|
||||||
|
"fe-auth-refresh"
|
||||||
|
);
|
||||||
|
const res = await fetch("/api/auth/refresh", init);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
log.debug("[Auth] Token refresh rejected (unauthenticated)", { status: res.status, correlationId });
|
||||||
|
} else {
|
||||||
|
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",
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password, rememberMe }),
|
credentials: "include",
|
||||||
});
|
body: JSON.stringify({ username, password, rememberMe }),
|
||||||
|
},
|
||||||
|
"fe-auth-login"
|
||||||
|
);
|
||||||
|
log.info("[Auth] Login requested", { username, rememberMe, correlationId });
|
||||||
|
const res = await fetch("/api/auth/login", init);
|
||||||
|
|
||||||
if (!res.ok) {
|
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 +239,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", {
|
const { correlationId, init } = withCorrelation(
|
||||||
method: "POST",
|
{
|
||||||
credentials: "include",
|
method: "POST",
|
||||||
});
|
credentials: "include",
|
||||||
|
},
|
||||||
|
"fe-auth-logout"
|
||||||
|
);
|
||||||
|
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||||
|
await fetch("/api/auth/logout", init);
|
||||||
setUser(null);
|
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 +280,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();
|
||||||
@@ -377,7 +429,7 @@ export function LoginForm({
|
|||||||
</svg>
|
</svg>
|
||||||
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||||
</button>
|
</button>
|
||||||
{authState?.localAuthEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<div className="auth-divider">
|
<div className="auth-divider">
|
||||||
<span>{t("auth.or", "or")}</span>
|
<span>{t("auth.or", "or")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,8 +437,8 @@ export function LoginForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Local Login Form - only show if local auth is enabled */}
|
{/* Local login form - only show if form login is enabled */}
|
||||||
{authState?.localAuthEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
@@ -426,7 +478,7 @@ export function LoginForm({
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authState?.registrationEnabled && authState?.localAuthEnabled && onSwitchToRegister && (
|
{authState?.registrationEnabled && authState?.formLoginEnabled && onSwitchToRegister && (
|
||||||
<div className="auth-links">
|
<div className="auth-links">
|
||||||
<button type="button" className="auth-link-btn" onClick={onSwitchToRegister}>
|
<button type="button" className="auth-link-btn" onClick={onSwitchToRegister}>
|
||||||
{t("auth.createAccount", "Create account")}
|
{t("auth.createAccount", "Create account")}
|
||||||
@@ -492,7 +544,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
</svg>
|
</svg>
|
||||||
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
{t("auth.loginWithSSO", "Login with {{provider}}", { provider: authState.oidcProviderName || "SSO" })}
|
||||||
</button>
|
</button>
|
||||||
{authState?.localAuthEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<div className="auth-divider">
|
<div className="auth-divider">
|
||||||
<span>{t("auth.or", "or")}</span>
|
<span>{t("auth.or", "or")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,7 +553,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Local Registration Form - only show if local auth is enabled */}
|
{/* Local Registration Form - only show if local auth is enabled */}
|
||||||
{authState?.localAuthEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
@@ -574,34 +626,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 +660,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 +761,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 +808,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;
|
||||||
@@ -27,29 +28,22 @@ 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
|
<div
|
||||||
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onCancel();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content"
|
className="modal-content confirm-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
style={{ maxWidth: "450px" }}
|
style={{ maxWidth: "450px" }}
|
||||||
>
|
>
|
||||||
<button className="modal-close" onClick={onCancel}>
|
<button className="modal-close" onClick={onCancel}>
|
||||||
|
|||||||
@@ -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,6 +12,9 @@ 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 (
|
||||||
@@ -17,13 +22,15 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content"
|
className="modal-content"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
style={{ maxWidth: "450px" }}
|
style={{ maxWidth: "450px" }}
|
||||||
>
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
@@ -64,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,7 +3,7 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
|
||||||
export interface LightboxProps {
|
export interface LightboxProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -12,16 +12,7 @@ export interface LightboxProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||||
useEffect(() => {
|
useEscapeKey(true, onClose);
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
function handleOverlayClick(e: MouseEvent) {
|
function handleOverlayClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -31,7 +22,13 @@ 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}>
|
||||||
×
|
×
|
||||||
@@ -41,7 +38,9 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
|||||||
alt={alt}
|
alt={alt}
|
||||||
className="lightbox-image"
|
className="lightbox-image"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,40 +6,31 @@
|
|||||||
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
|
||||||
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
* 2. Props mode: Accepts all required data as props (for gradual adoption)
|
||||||
*/
|
*/
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses label-styled wrappers with custom interactive rows */
|
||||||
|
/* biome-ignore-all lint/style/noNestedTernary: stock/preview rendering keeps explicit branch mapping */
|
||||||
|
|
||||||
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
|
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Lightbox, MedicationAvatar } from "../components";
|
import { Lightbox, MedicationAvatar } from "../components";
|
||||||
|
import { useEscapeKey } from "../hooks";
|
||||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||||
import { getMedTotal, getPackageSize } from "../types";
|
import {
|
||||||
|
getMedDisplayName,
|
||||||
|
getMedTotal,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Local Helper Functions
|
// Local Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate blister stock - divides current pills into full blisters and partial
|
|
||||||
*/
|
|
||||||
function getBlisterStock(
|
|
||||||
currentPills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
originalLooseTablets: number,
|
|
||||||
_originalTotalPills: number
|
|
||||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
|
||||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
|
||||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
|
||||||
}
|
|
||||||
const safeCurrent = Math.max(0, currentPills);
|
|
||||||
const loosePills = Math.min(safeCurrent, Math.max(0, originalLooseTablets));
|
|
||||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
|
||||||
const fullBlisters = Math.floor(sealedPills / pillsPerBlister);
|
|
||||||
const openBlisterPills = sealedPills % pillsPerBlister;
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format full blisters column
|
* Format full blisters column
|
||||||
*/
|
*/
|
||||||
@@ -172,21 +163,11 @@ export function MedDetailModal({
|
|||||||
}
|
}
|
||||||
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills]);
|
}, [showEditStockModal, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Escape key: only one handler is active at a time (sub-modal states are mutually exclusive).
|
||||||
if (!showEditStockModal) return;
|
// Lightbox has its own useEscapeKey internally.
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
useEscapeKey(!showEditStockModal && !showImageLightbox && !showRefillModal, onClose);
|
||||||
if (event.key === "Escape") {
|
useEscapeKey(showEditStockModal, onCloseEditStockModal, { capture: true });
|
||||||
event.stopPropagation();
|
useEscapeKey(showRefillModal, onCloseRefillModal, { capture: true });
|
||||||
if (typeof event.stopImmediatePropagation === "function") {
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
onCloseEditStockModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape, true);
|
|
||||||
return () => document.removeEventListener("keydown", handleEscape, true);
|
|
||||||
}, [showEditStockModal, onCloseEditStockModal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showEditStockModal) return;
|
if (showEditStockModal) return;
|
||||||
@@ -196,7 +177,7 @@ export function MedDetailModal({
|
|||||||
}, [showEditStockModal]);
|
}, [showEditStockModal]);
|
||||||
|
|
||||||
const remainingPrescriptionRefills = Math.max(0, Number(selectedMed?.prescriptionRemainingRefills) || 0);
|
const remainingPrescriptionRefills = Math.max(0, Number(selectedMed?.prescriptionRemainingRefills) || 0);
|
||||||
const prescriptionPackCapEnabled = selectedMed?.packageType === "blister" && usePrescriptionRefill;
|
const prescriptionPackCapEnabled = !isAmountBasedPackageType(selectedMed?.packageType) && usePrescriptionRefill;
|
||||||
const cappedRefillPacks = prescriptionPackCapEnabled
|
const cappedRefillPacks = prescriptionPackCapEnabled
|
||||||
? Math.min(refillPacks, remainingPrescriptionRefills)
|
? Math.min(refillPacks, remainingPrescriptionRefills)
|
||||||
: refillPacks;
|
: refillPacks;
|
||||||
@@ -205,7 +186,7 @@ export function MedDetailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
if (!showRefillModal) return;
|
if (!showRefillModal) return;
|
||||||
if (selectedMed.packageType !== "blister" || !usePrescriptionRefill) return;
|
if (isAmountBasedPackageType(selectedMed.packageType) || !usePrescriptionRefill) return;
|
||||||
if (refillPacks <= remainingPrescriptionRefills) return;
|
if (refillPacks <= remainingPrescriptionRefills) return;
|
||||||
onRefillPacksChange(remainingPrescriptionRefills);
|
onRefillPacksChange(remainingPrescriptionRefills);
|
||||||
}, [
|
}, [
|
||||||
@@ -218,26 +199,43 @@ export function MedDetailModal({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!selectedMed) return null;
|
if (!selectedMed) return null;
|
||||||
|
const isAmountPackage =
|
||||||
|
isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType);
|
||||||
|
const amountUnitLabel =
|
||||||
|
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"
|
||||||
|
? t("form.packageAmountUnitMl")
|
||||||
|
: t("form.packageAmountUnitG");
|
||||||
|
const stockUnitLabel = isAmountPackage ? amountUnitLabel : null;
|
||||||
|
|
||||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
|
||||||
const packageSize = getPackageSize(selectedMed);
|
const packageSize = getPackageSize(selectedMed);
|
||||||
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
|
||||||
const structuralMax =
|
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "bottle"
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
? (selectedMed.totalPills ?? packageSize)
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
|
||||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||||
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
||||||
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
||||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
const stock = splitCurrentBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets);
|
||||||
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
||||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||||
const pillsPerPack = Math.max(1, selectedMed.blistersPerPack * selectedMed.pillsPerBlister);
|
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
const remainingPacks = Math.max(0, Math.ceil(Math.max(0, currentStock) / pillsPerPack));
|
? (selectedMed.totalPills ?? packageSize)
|
||||||
const stockDisplayTotal =
|
: Math.max(0, structuralMax);
|
||||||
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, currentStock);
|
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
|
||||||
|
const amountPerPackage = (() => {
|
||||||
|
const configured = Number(selectedMed.packageAmountValue ?? 0);
|
||||||
|
if (Number.isFinite(configured) && configured > 0) return configured;
|
||||||
|
|
||||||
|
const totalAmount = Number(stockDisplayTotal ?? 0);
|
||||||
|
if (Number.isFinite(totalAmount) && totalAmount > 0) {
|
||||||
|
return Math.max(0, totalAmount / packageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
const maxPartialPills = Math.min(
|
const maxPartialPills = Math.min(
|
||||||
Math.max(0, selectedMed.pillsPerBlister),
|
Math.max(0, selectedMed.pillsPerBlister),
|
||||||
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
||||||
@@ -247,6 +245,33 @@ export function MedDetailModal({
|
|||||||
const closeLabel = t("common.close");
|
const closeLabel = t("common.close");
|
||||||
const decrementLabel = t("editStock.decreaseValue");
|
const decrementLabel = t("editStock.decreaseValue");
|
||||||
const incrementLabel = t("editStock.increaseValue");
|
const incrementLabel = t("editStock.increaseValue");
|
||||||
|
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||||
|
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||||
|
if (intakeUnit === "tsp") {
|
||||||
|
return `${usage} ${t("form.blisters.teaspoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
if (intakeUnit === "tbsp") {
|
||||||
|
return `${usage} ${t("form.blisters.tablespoons", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
if (isTubePackageType(selectedMed.packageType)) {
|
||||||
|
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
const scheduleIntakes =
|
||||||
|
selectedMed.intakes && selectedMed.intakes.length > 0
|
||||||
|
? selectedMed.intakes
|
||||||
|
: selectedMed.blisters.map((blister) => ({
|
||||||
|
usage: blister.usage,
|
||||||
|
every: blister.every,
|
||||||
|
start: blister.start,
|
||||||
|
takenBy: null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
intakeUnit: null,
|
||||||
|
}));
|
||||||
|
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
|
||||||
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
|
||||||
let normalizedFull = Math.max(0, nextFull);
|
let normalizedFull = Math.max(0, nextFull);
|
||||||
let normalizedPartial = Math.max(0, nextPartial);
|
let normalizedPartial = Math.max(0, nextPartial);
|
||||||
@@ -294,6 +319,14 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="number-stepper refill-number-stepper">
|
<div className="number-stepper refill-number-stepper">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="stepper-btn decrement"
|
className="stepper-btn decrement"
|
||||||
@@ -303,14 +336,6 @@ export function MedDetailModal({
|
|||||||
>
|
>
|
||||||
<Minus size={16} aria-hidden="true" />
|
<Minus size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="stepper-btn increment"
|
className="stepper-btn increment"
|
||||||
@@ -340,16 +365,7 @@ export function MedDetailModal({
|
|||||||
const canIncrement = clamped < max;
|
const canIncrement = clamped < max;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="number-stepper">
|
<div className="number-stepper refill-number-stepper">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stepper-btn decrement"
|
|
||||||
onClick={() => onChange(Math.max(min, clamped - 1))}
|
|
||||||
disabled={!canDecrement}
|
|
||||||
aria-label={decrementLabel}
|
|
||||||
>
|
|
||||||
<Minus size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={min}
|
min={min}
|
||||||
@@ -360,6 +376,15 @@ export function MedDetailModal({
|
|||||||
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
|
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="stepper-btn decrement"
|
||||||
|
onClick={() => onChange(Math.max(min, clamped - 1))}
|
||||||
|
disabled={!canDecrement}
|
||||||
|
aria-label={decrementLabel}
|
||||||
|
>
|
||||||
|
<Minus size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="stepper-btn increment"
|
className="stepper-btn increment"
|
||||||
@@ -375,6 +400,10 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
const renderEditStockModal = () => {
|
const renderEditStockModal = () => {
|
||||||
if (!showEditStockModal) return null;
|
if (!showEditStockModal) return null;
|
||||||
|
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
|
||||||
|
const liquidBottleCount = Math.max(1, editStockFullBlisters);
|
||||||
|
const liquidAmountPerBottle = Math.max(1, Number.isFinite(amountPerPackage) ? amountPerPackage : 1);
|
||||||
|
const liquidCapacity = Math.max(1, Math.round(liquidBottleCount * liquidAmountPerBottle));
|
||||||
const fullInputMax = Math.min(
|
const fullInputMax = Math.min(
|
||||||
maxFullBlisters,
|
maxFullBlisters,
|
||||||
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
Math.floor(Math.max(0, structuralMax - Math.max(0, editStockPartialBlisterPills)) / selectedMed.pillsPerBlister)
|
||||||
@@ -389,20 +418,14 @@ export function MedDetailModal({
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.key === "Escape") onCloseEditStockModal();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content edit-stock-modal"
|
className="modal-content edit-stock-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDownCapture={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onCloseEditStockModal();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -414,9 +437,9 @@ export function MedDetailModal({
|
|||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<h2>{t("editStock.title")}</h2>
|
<h2>{t("editStock.title")}</h2>
|
||||||
<p className="edit-stock-med-name">{selectedMed.name}</p>
|
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
|
||||||
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
<p className="edit-stock-hint">{t("editStock.hint")}</p>
|
||||||
{selectedMed.packageType === "blister" && (
|
{!isAmountBasedPackageType(selectedMed.packageType) && (
|
||||||
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
||||||
{t("editStock.currentComposition", {
|
{t("editStock.currentComposition", {
|
||||||
fullBlisters: currentFullBlisters,
|
fullBlisters: currentFullBlisters,
|
||||||
@@ -426,9 +449,15 @@ export function MedDetailModal({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{selectedMed.packageType === "bottle" && (
|
{isAmountBasedPackageType(selectedMed.packageType) && !isTubePackageType(selectedMed.packageType) && (
|
||||||
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-info">{t("editStock.packageSize", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
|
{(isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType)) && (
|
||||||
|
<p className="edit-stock-cap-info">
|
||||||
|
{t("form.totalAmount")}: {formatNumber(isLiquidPackage ? liquidCapacity : structuralMax)}{" "}
|
||||||
|
{amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{showStockCapNotice && (
|
{showStockCapNotice && (
|
||||||
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
<p className="edit-stock-cap-warning">{t("editStock.maxExceeded", { count: structuralMax })}</p>
|
||||||
)}
|
)}
|
||||||
@@ -436,12 +465,14 @@ export function MedDetailModal({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||||
const isBottle = selectedMed.packageType === "bottle";
|
const isBottle = isAmountBasedPackageType(selectedMed.packageType);
|
||||||
const enteredTotal = isBottle
|
const enteredTotal = isLiquidPackage
|
||||||
? editStockPartialBlisterPills
|
? Math.min(liquidCapacity, editStockPartialBlisterPills)
|
||||||
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
: isBottle
|
||||||
editStockPartialBlisterPills +
|
? editStockPartialBlisterPills
|
||||||
editStockLoosePills;
|
: editStockFullBlisters * selectedMed.pillsPerBlister +
|
||||||
|
editStockPartialBlisterPills +
|
||||||
|
editStockLoosePills;
|
||||||
const newTotal = Math.max(0, enteredTotal);
|
const newTotal = Math.max(0, enteredTotal);
|
||||||
const difference = newTotal - currentTotal;
|
const difference = newTotal - currentTotal;
|
||||||
const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : "";
|
const differenceClass = difference > 0 ? "positive" : difference < 0 ? "negative" : "";
|
||||||
@@ -451,36 +482,39 @@ export function MedDetailModal({
|
|||||||
<div className="edit-stock-form">
|
<div className="edit-stock-form">
|
||||||
{isBottle ? (
|
{isBottle ? (
|
||||||
<label>
|
<label>
|
||||||
{t("editStock.totalPills")}
|
{isAmountPackage ? t("form.currentAmount") : t("editStock.totalPills")}
|
||||||
{renderStepperInput({
|
{renderStepperInput({
|
||||||
value: editStockPartialInput,
|
value: editStockPartialInput,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: structuralMax,
|
max: isLiquidPackage ? liquidCapacity : structuralMax,
|
||||||
onChange: (raw) => {
|
onChange: (raw) => {
|
||||||
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
const parsed = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||||
setEditStockPartialInput(raw);
|
setEditStockPartialInput(raw);
|
||||||
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(structuralMax, parsed));
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
setShowStockCapNotice(parsed > structuralMax);
|
onEditStockPartialBlisterPillsChange(raw === "" ? 0 : Math.min(maxTotal, parsed));
|
||||||
|
setShowStockCapNotice(parsed > maxTotal);
|
||||||
},
|
},
|
||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
const normalized = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const normalized = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput)));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput))
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(normalized);
|
onEditStockPartialBlisterPillsChange(normalized);
|
||||||
setEditStockPartialInput(String(normalized));
|
setEditStockPartialInput(String(normalized));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
onStep: (delta) => {
|
onStep: (delta) => {
|
||||||
const next = Math.min(
|
const maxTotal = isLiquidPackage ? liquidCapacity : structuralMax;
|
||||||
structuralMax,
|
const next = Math.min(maxTotal, Math.max(0, parseStockInput(editStockPartialInput) + delta));
|
||||||
Math.max(0, parseStockInput(editStockPartialInput) + delta)
|
|
||||||
);
|
|
||||||
onEditStockPartialBlisterPillsChange(next);
|
onEditStockPartialBlisterPillsChange(next);
|
||||||
setEditStockPartialInput(String(next));
|
setEditStockPartialInput(String(next));
|
||||||
setShowStockCapNotice(false);
|
setShowStockCapNotice(false);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
|
||||||
|
{t("form.currentAmount")}: {Math.max(0, editStockPartialBlisterPills)} {amountUnitLabel} /{" "}
|
||||||
|
{liquidCapacity} {amountUnitLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -495,7 +529,7 @@ export function MedDetailModal({
|
|||||||
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
|
||||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||||
setEditStockFullInput(raw);
|
setEditStockFullInput(raw);
|
||||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
@@ -524,7 +558,7 @@ export function MedDetailModal({
|
|||||||
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
|
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
|
||||||
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
|
||||||
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
|
||||||
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||||
@@ -581,7 +615,7 @@ export function MedDetailModal({
|
|||||||
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
|
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
|
||||||
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
|
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
|
||||||
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
|
||||||
const rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
const _rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
|
||||||
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
|
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
|
||||||
onEditStockFullBlistersChange(normalized.full);
|
onEditStockFullBlistersChange(normalized.full);
|
||||||
onEditStockPartialBlisterPillsChange(normalized.partial);
|
onEditStockPartialBlisterPillsChange(normalized.partial);
|
||||||
@@ -618,26 +652,72 @@ export function MedDetailModal({
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isLiquidPackage && (
|
||||||
|
<label>
|
||||||
|
{t("form.bottles")}
|
||||||
|
{renderStepperInput({
|
||||||
|
value: editStockFullInput,
|
||||||
|
min: 1,
|
||||||
|
max: Number.MAX_SAFE_INTEGER,
|
||||||
|
onChange: (raw) => {
|
||||||
|
const nextBottleCount = raw === "" ? 1 : Math.max(1, parseStockInput(raw));
|
||||||
|
setEditStockFullInput(raw === "" ? "1" : raw);
|
||||||
|
onEditStockFullBlistersChange(nextBottleCount);
|
||||||
|
const syncedTotal = Math.round(nextBottleCount * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onBlur: () => {
|
||||||
|
const normalized = Math.max(1, parseStockInput(editStockFullInput));
|
||||||
|
onEditStockFullBlistersChange(normalized);
|
||||||
|
setEditStockFullInput(String(normalized));
|
||||||
|
const syncedTotal = Math.round(normalized * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
onStep: (delta) => {
|
||||||
|
const next = Math.max(1, parseStockInput(editStockFullInput) + delta);
|
||||||
|
onEditStockFullBlistersChange(next);
|
||||||
|
setEditStockFullInput(String(next));
|
||||||
|
const syncedTotal = Math.round(next * liquidAmountPerBottle);
|
||||||
|
onEditStockPartialBlisterPillsChange(syncedTotal);
|
||||||
|
setEditStockPartialInput(String(syncedTotal));
|
||||||
|
setShowStockCapNotice(false);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="edit-stock-summary">
|
<div className="edit-stock-summary">
|
||||||
<div className="summary-row">
|
<div className="summary-row">
|
||||||
<span>{t("editStock.currentTotal")}:</span>
|
<span>{t("editStock.currentTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
|
{currentTotal}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="summary-row">
|
<div className="summary-row">
|
||||||
<span>{t("editStock.newTotal")}:</span>
|
<span>{t("editStock.newTotal")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
|
{newTotal}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`summary-row difference ${differenceClass}`}>
|
<div className={`summary-row difference ${differenceClass}`}>
|
||||||
<span>{t("editStock.difference")}:</span>
|
<span>{t("editStock.difference")}:</span>
|
||||||
<span>
|
<span>
|
||||||
{difference > 0 ? "+" : ""}
|
{difference > 0 ? "+" : ""}
|
||||||
{difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}
|
{difference}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,7 +727,7 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="ghost" onClick={onCloseEditStockModal}>
|
<button className="ghost" onClick={onCloseEditStockModal}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
|
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
|
||||||
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
||||||
@@ -667,8 +747,7 @@ export function MedDetailModal({
|
|||||||
className="modal-overlay med-detail-overlay"
|
className="modal-overlay med-detail-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (showEditStockModal) return;
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -676,14 +755,9 @@ export function MedDetailModal({
|
|||||||
ref={detailModalRef}
|
ref={detailModalRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDownCapture={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -707,18 +781,20 @@ export function MedDetailModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
|
<MedicationAvatar name={getMedDisplayName(selectedMed)} imageUrl={selectedMed.imageUrl} size="lg" />
|
||||||
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="med-detail-titles">
|
<div className="med-detail-titles">
|
||||||
<h2>{selectedMed.name}</h2>
|
<h2>{getMedDisplayName(selectedMed)}</h2>
|
||||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
{selectedMed.name && selectedMed.genericName && (
|
||||||
|
<span className="med-generic-name">{selectedMed.genericName}</span>
|
||||||
|
)}
|
||||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||||
<span className="med-taken-by">
|
<span className="med-taken-by">
|
||||||
{t("modal.for")}{" "}
|
{t("modal.for")}{" "}
|
||||||
{selectedMed.takenBy.map((person, index) => (
|
{selectedMed.takenBy.map((person, index) => (
|
||||||
<span key={person}>
|
<span key={person} style={{ whiteSpace: "nowrap" }}>
|
||||||
{index > 0 && ", "}
|
{index > 0 && (index === selectedMed.takenBy.length - 1 ? ` ${t("common.and")} ` : ", ")}
|
||||||
{person}
|
{person}
|
||||||
{selectedMed.intakes?.some(
|
{selectedMed.intakes?.some(
|
||||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||||
@@ -734,7 +810,7 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>{t("modal.stockInfo")}</h3>
|
<h3>{t("modal.stockInfo")}</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" && (
|
{!isAmountBasedPackageType(selectedMed.packageType) && (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
<span className="med-detail-label">{t("table.fullBlisters")}</span>
|
||||||
@@ -753,10 +829,14 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
|
<div className="med-detail-item full-width">
|
||||||
<span className="med-detail-label">{t("modal.currentStock")}</span>
|
<span className="med-detail-label">
|
||||||
|
{isAmountPackage ? t("form.currentAmount") : t("modal.currentStock")}
|
||||||
|
</span>
|
||||||
<span className={`med-detail-value ${textClass}`}>
|
<span className={`med-detail-value ${textClass}`}>
|
||||||
{currentStock} / {stockDisplayTotal}
|
{isAmountPackage
|
||||||
|
? `${formatNumber(currentStock)} / ${formatNumber(stockDisplayTotal)} ${amountUnitLabel}`
|
||||||
|
: `${currentStock} / ${stockDisplayTotal}`}
|
||||||
{currentStock > stockDisplayTotal && (
|
{currentStock > stockDisplayTotal && (
|
||||||
<span
|
<span
|
||||||
className="info-tooltip tooltip-align-left warning-text"
|
className="info-tooltip tooltip-align-left warning-text"
|
||||||
@@ -775,14 +855,31 @@ export function MedDetailModal({
|
|||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
{t("modal.packageDetails")} (
|
{t("modal.packageDetails")} (
|
||||||
{selectedMed.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")})
|
{isTubePackageType(selectedMed.packageType)
|
||||||
|
? t("form.packageTypeTube")
|
||||||
|
: isLiquidContainerPackageType(selectedMed.packageType)
|
||||||
|
? t("form.packageTypeLiquidContainer")
|
||||||
|
: isAmountBasedPackageType(selectedMed.packageType)
|
||||||
|
? t("form.packageTypeBottle")
|
||||||
|
: t("form.packageTypeBlister")}
|
||||||
|
)
|
||||||
|
{isTubePackageType(selectedMed.packageType) && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeTubeHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLiquidContainerPackageType(selectedMed.packageType) && (
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("modal.packageTypeLiquidHint")}>
|
||||||
|
ℹ️
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{!isAmountBasedPackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||||
<span className="med-detail-value">{remainingPacks}</span>
|
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||||
@@ -793,6 +890,44 @@ export function MedDetailModal({
|
|||||||
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : isLiquidContainerPackageType(selectedMed.packageType) ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.bottles")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerBottle")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : isTubePackageType(selectedMed.packageType) ? (
|
||||||
|
<>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.tubes")}</span>
|
||||||
|
<span className="med-detail-value">{packageCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.packageAmountPerTube")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(amountPerPackage)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="med-detail-item">
|
||||||
|
<span className="med-detail-label">{t("form.totalAmount")}</span>
|
||||||
|
<span className="med-detail-value">
|
||||||
|
{formatNumber(stockDisplayTotal)} {amountUnitLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
<span className="med-detail-label">{t("form.totalCapacity")}</span>
|
||||||
@@ -824,6 +959,57 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Intake Schedule Section */}
|
||||||
|
{selectedMed.blisters.length > 0 && (
|
||||||
|
<div className="med-detail-section">
|
||||||
|
<h3>
|
||||||
|
{t("modal.intakeSchedule")}{" "}
|
||||||
|
{hasAnyIntakeReminder && (
|
||||||
|
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||||
|
<Bell size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="med-detail-schedules">
|
||||||
|
{scheduleIntakes.map((intake, idx) => {
|
||||||
|
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||||
|
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||||
|
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||||
|
const showIntakeBell = intake.intakeRemindersEnabled === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`}
|
||||||
|
className="med-schedule-row blister-row-simple"
|
||||||
|
>
|
||||||
|
<span className="med-schedule-usage">
|
||||||
|
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||||
|
{selectedMed.pillWeightMg &&
|
||||||
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
|
</span>
|
||||||
|
<span className="med-schedule-freq">
|
||||||
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||||
|
</span>
|
||||||
|
{hasPerIntakeTakenBy && <span className="med-schedule-person">{intake.takenBy}</span>}
|
||||||
|
<span className="med-schedule-time">
|
||||||
|
{t("modal.at")}{" "}
|
||||||
|
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{showIntakeBell && (
|
||||||
|
<span className="med-schedule-bell" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<Bell size={12} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prescription Details Section */}
|
{/* Prescription Details Section */}
|
||||||
{selectedMed.prescriptionEnabled && (
|
{selectedMed.prescriptionEnabled && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
@@ -860,50 +1046,6 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Intake Schedule Section */}
|
|
||||||
{selectedMed.blisters.length > 0 && (
|
|
||||||
<div className="med-detail-section">
|
|
||||||
<h3>
|
|
||||||
{t("modal.intakeSchedule")}{" "}
|
|
||||||
{selectedMed.intakeRemindersEnabled && (
|
|
||||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={14} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="med-detail-schedules">
|
|
||||||
{selectedMed.blisters.map((blister, idx) => {
|
|
||||||
// When using new intakes format with per-intake takenBy,
|
|
||||||
// each intake already represents one person's dose — don't multiply.
|
|
||||||
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
|
||||||
const intake = selectedMed.intakes?.[idx];
|
|
||||||
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
|
||||||
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
|
||||||
const totalUsage = blister.usage * personCount;
|
|
||||||
return (
|
|
||||||
<div key={idx} className="med-schedule-item">
|
|
||||||
<span className="med-schedule-usage">
|
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
{selectedMed.pillWeightMg &&
|
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
|
||||||
</span>
|
|
||||||
<span className="med-schedule-freq">
|
|
||||||
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
|
||||||
</span>
|
|
||||||
<span className="med-schedule-time">
|
|
||||||
{t("modal.at")}{" "}
|
|
||||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Coverage Status Section */}
|
{/* Coverage Status Section */}
|
||||||
{medCoverage && status && (
|
{medCoverage && status && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
@@ -930,10 +1072,10 @@ export function MedDetailModal({
|
|||||||
{selectedMed.notes && (
|
{selectedMed.notes && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
|
{t("modal.notes")}{" "}
|
||||||
<span className="notes-icon notes-icon-static" aria-hidden="true">
|
<span className="notes-icon notes-icon-static" aria-hidden="true">
|
||||||
<NotebookPen size={14} />
|
<NotebookPen size={14} />
|
||||||
</span>{" "}
|
</span>
|
||||||
{t("modal.notes")}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-notes-content">{selectedMed.notes}</div>
|
<div className="med-notes-content">{selectedMed.notes}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -970,12 +1112,11 @@ export function MedDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
<span className="refill-amount">
|
<span className="refill-amount">
|
||||||
{(() => {
|
{(() => {
|
||||||
const total =
|
const total = isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "bottle"
|
? entry.loosePillsAdded
|
||||||
? entry.loosePillsAdded
|
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
||||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
entry.loosePillsAdded;
|
||||||
entry.loosePillsAdded;
|
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||||
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
|
|
||||||
})()}
|
})()}
|
||||||
{entry.usedPrescription && (
|
{entry.usedPrescription && (
|
||||||
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
|
||||||
@@ -990,51 +1131,56 @@ export function MedDetailModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Footer */}
|
</div>
|
||||||
<div className="med-detail-footer">
|
|
||||||
<button onClick={onClose}>{t("common.close")}</button>
|
{/* Footer */}
|
||||||
<div className="footer-actions">
|
<div className="med-detail-footer">
|
||||||
<button className="success" onClick={onOpenRefillModal}>
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
{t("refill.button")}
|
<div className="footer-actions">
|
||||||
|
<button className="success" onClick={onOpenRefillModal}>
|
||||||
|
{t("refill.button")}
|
||||||
|
</button>
|
||||||
|
{onOpenMedicationEdit && (
|
||||||
|
<button
|
||||||
|
className="info icon-only tooltip-trigger"
|
||||||
|
onClick={onOpenMedicationEdit}
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
data-tooltip={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{onOpenMedicationEdit && (
|
)}
|
||||||
<button
|
{onOpenEditStockModal && (
|
||||||
className="info icon-only tooltip-trigger"
|
<button
|
||||||
onClick={onOpenMedicationEdit}
|
className="icon-stock-correction icon-only tooltip-trigger"
|
||||||
aria-label={t("common.edit")}
|
onClick={onOpenEditStockModal}
|
||||||
data-tooltip={t("common.edit")}
|
aria-label={t("editStock.buttonLabel")}
|
||||||
>
|
data-tooltip={t("editStock.buttonLabel")}
|
||||||
<Pencil size={18} aria-hidden="true" />
|
>
|
||||||
</button>
|
<FilePenLine size={18} aria-hidden="true" />
|
||||||
)}
|
</button>
|
||||||
{onOpenEditStockModal && (
|
)}
|
||||||
<button
|
{selectedMed.blisters.length > 0 && (
|
||||||
className="icon-stock-correction icon-only tooltip-trigger"
|
<button
|
||||||
onClick={onOpenEditStockModal}
|
className="secondary icon-only tooltip-trigger"
|
||||||
aria-label={t("editStock.buttonLabel")}
|
onClick={() => generateICS(selectedMed)}
|
||||||
data-tooltip={t("editStock.buttonLabel")}
|
aria-label={t("modal.exportTooltip")}
|
||||||
>
|
data-tooltip={t("modal.exportTooltip")}
|
||||||
<FilePenLine size={18} aria-hidden="true" />
|
>
|
||||||
</button>
|
<Calendar size={18} aria-hidden="true" />
|
||||||
)}
|
</button>
|
||||||
{selectedMed.blisters.length > 0 && (
|
)}
|
||||||
<button
|
|
||||||
className="secondary icon-only tooltip-trigger"
|
|
||||||
onClick={() => generateICS(selectedMed)}
|
|
||||||
aria-label={t("modal.exportTooltip")}
|
|
||||||
data-tooltip={t("modal.exportTooltip")}
|
|
||||||
>
|
|
||||||
<Calendar size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Lightbox */}
|
{/* Image Lightbox */}
|
||||||
{showImageLightbox && selectedMed.imageUrl && (
|
{showImageLightbox && selectedMed.imageUrl && (
|
||||||
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
|
<Lightbox
|
||||||
|
src={`/api/images/${selectedMed.imageUrl}`}
|
||||||
|
alt={getMedDisplayName(selectedMed)}
|
||||||
|
onClose={onCloseImageLightbox}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refill Modal */}
|
{/* Refill Modal */}
|
||||||
@@ -1046,14 +1192,15 @@ export function MedDetailModal({
|
|||||||
onCloseRefillModal();
|
onCloseRefillModal();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
if (e.key === "Escape") onCloseRefillModal();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content refill-modal"
|
className="modal-content refill-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1065,10 +1212,10 @@ export function MedDetailModal({
|
|||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<h2>{t("refill.title")}</h2>
|
<h2>{t("refill.title")}</h2>
|
||||||
<p className="refill-med-name">{selectedMed.name}</p>
|
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
|
||||||
|
|
||||||
<div className="refill-form">
|
<div className="refill-form">
|
||||||
{selectedMed.packageType === "blister" ? (
|
{!isAmountBasedPackageType(selectedMed.packageType) ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("refill.packs")}
|
{t("refill.packs")}
|
||||||
@@ -1112,7 +1259,7 @@ export function MedDetailModal({
|
|||||||
onUsePrescriptionRefillChange(checked);
|
onUsePrescriptionRefillChange(checked);
|
||||||
if (
|
if (
|
||||||
checked &&
|
checked &&
|
||||||
selectedMed.packageType === "blister" &&
|
!isAmountBasedPackageType(selectedMed.packageType) &&
|
||||||
refillPacks > remainingPrescriptionRefills
|
refillPacks > remainingPrescriptionRefills
|
||||||
) {
|
) {
|
||||||
onRefillPacksChange(remainingPrescriptionRefills);
|
onRefillPacksChange(remainingPrescriptionRefills);
|
||||||
@@ -1131,14 +1278,14 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="ghost" onClick={onCloseRefillModal}>
|
<button className="ghost" onClick={onCloseRefillModal}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<div className="refill-footer-right">
|
<div className="refill-footer-right">
|
||||||
<button
|
<button
|
||||||
className="success"
|
className="success"
|
||||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedMed.packageType === "bottle"
|
(isAmountBasedPackageType(selectedMed.packageType)
|
||||||
? refillLoose < 1
|
? refillLoose < 1
|
||||||
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
||||||
exceedsPrescriptionPackLimit ||
|
exceedsPrescriptionPackLimit ||
|
||||||
@@ -1148,13 +1295,15 @@ export function MedDetailModal({
|
|||||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||||
</button>
|
</button>
|
||||||
{(() => {
|
{(() => {
|
||||||
const totalRefill =
|
const totalRefill = !isAmountBasedPackageType(selectedMed.packageType)
|
||||||
selectedMed.packageType === "blister"
|
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
||||||
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
: refillLoose;
|
||||||
: refillLoose;
|
|
||||||
return totalRefill > 0 ? (
|
return totalRefill > 0 ? (
|
||||||
<span className="refill-preview">
|
<span className="refill-preview">
|
||||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
+{totalRefill}
|
||||||
|
{isAmountPackage
|
||||||
|
? ` ${stockUnitLabel}`
|
||||||
|
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// MedicationAvatar Component
|
// MedicationAvatar Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export type MedicationAvatarProps = {
|
export type MedicationAvatarProps = {
|
||||||
name: string;
|
name: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -9,6 +11,15 @@ export type MedicationAvatarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
export function MedicationAvatar({ name, imageUrl, size = "sm" }: MedicationAvatarProps) {
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false);
|
||||||
|
const previousImageUrlRef = useRef(imageUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousImageUrlRef.current === imageUrl) return;
|
||||||
|
previousImageUrlRef.current = imageUrl;
|
||||||
|
setThumbFailed(false);
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
const initials =
|
const initials =
|
||||||
name
|
name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -19,7 +30,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ interface ProfileModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||||
|
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,13 +15,15 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content profile-modal"
|
className="modal-content profile-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, 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 { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
import { getPackageSize } from "../types";
|
import {
|
||||||
|
getMedDisplayName,
|
||||||
|
getPackageSize,
|
||||||
|
isAmountBasedPackageType,
|
||||||
|
isLiquidContainerPackageType,
|
||||||
|
isTubePackageType,
|
||||||
|
} from "../types";
|
||||||
import { MedicationAvatar } from "./MedicationAvatar";
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
|
||||||
type ReportFormat = "txt" | "md" | "pdf";
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
@@ -16,6 +24,7 @@ type ReportData = Record<
|
|||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesDismissed: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
@@ -30,6 +39,9 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useScrollLock(isOpen);
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
// Collect all unique "taken by" people across all medications
|
// Collect all unique "taken by" people across all medications
|
||||||
const allPeople = useMemo(() => {
|
const allPeople = useMemo(() => {
|
||||||
const people = new Set<string>();
|
const people = new Set<string>();
|
||||||
@@ -137,13 +149,15 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content report-modal"
|
className="modal-content report-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
@@ -192,10 +206,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
{activeMeds.map((med) => (
|
{activeMeds.map((med) => (
|
||||||
<label key={med.id} className="report-med-item">
|
<label key={med.id} className="report-med-item">
|
||||||
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
|
||||||
<span className="report-med-name">
|
<span className="report-med-name">
|
||||||
{med.name}
|
{getMedDisplayName(med)}
|
||||||
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -210,10 +224,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
{obsoleteMeds.map((med) => (
|
{obsoleteMeds.map((med) => (
|
||||||
<label key={med.id} className="report-med-item">
|
<label key={med.id} className="report-med-item">
|
||||||
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
|
||||||
<span className="report-med-name obsolete-name">
|
<span className="report-med-name obsolete-name">
|
||||||
{med.name}
|
{getMedDisplayName(med)}
|
||||||
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -256,7 +270,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="report-actions">
|
<div className="report-actions">
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -290,6 +304,39 @@ function fmtDateTime(iso: string | null | undefined): string {
|
|||||||
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
|
||||||
|
if (isLiquidContainerPackageType(med.packageType)) return "form.ml";
|
||||||
|
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageText(med: Medication, usage: number, t: TFn): string {
|
||||||
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
|
return `${usage} ${t(getTubeUnitKey(med))}`;
|
||||||
|
}
|
||||||
|
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalCapacityLabel(med: Medication, t: TFn): string {
|
||||||
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
|
return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) });
|
||||||
|
}
|
||||||
|
return t("report.docTotalCapacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentStockText(med: Medication, t: TFn): string {
|
||||||
|
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
|
||||||
|
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
|
||||||
|
}
|
||||||
|
return `${getPackageSize(med)} ${t("common.pills")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
|
||||||
|
if (isTubePackageType(med.packageType)) return t("report.docTube");
|
||||||
|
if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer");
|
||||||
|
if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle");
|
||||||
|
return t("report.docBlister");
|
||||||
|
}
|
||||||
|
|
||||||
function generateTextReport(
|
function generateTextReport(
|
||||||
meds: Medication[],
|
meds: Medication[],
|
||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
@@ -312,13 +359,15 @@ function generateTextReport(
|
|||||||
for (const med of meds) {
|
for (const med of meds) {
|
||||||
lines.push(sep);
|
lines.push(sep);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
|
const title = med.isObsolete
|
||||||
|
? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})`
|
||||||
|
: getMedDisplayName(med);
|
||||||
lines.push(h2(title));
|
lines.push(h2(title));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// General
|
// General
|
||||||
lines.push(h3(t("report.docGeneral")));
|
lines.push(h3(t("report.docGeneral")));
|
||||||
lines.push(item(t("report.docCommercialName"), med.name));
|
if (med.name) lines.push(item(t("report.docCommercialName"), med.name));
|
||||||
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
|
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
|
||||||
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
|
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -330,19 +379,18 @@ function generateTextReport(
|
|||||||
|
|
||||||
// Package / Stock
|
// Package / Stock
|
||||||
lines.push(h3(t("report.docPackage")));
|
lines.push(h3(t("report.docPackage")));
|
||||||
lines.push(
|
lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t)));
|
||||||
item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))
|
if (!isAmountBasedPackageType(med.packageType)) {
|
||||||
);
|
|
||||||
if (med.packageType === "blister") {
|
|
||||||
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
||||||
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
||||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||||
} else {
|
} else {
|
||||||
lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets)));
|
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||||
}
|
}
|
||||||
lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`));
|
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||||
if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
|
lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||||
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||||
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -355,7 +403,7 @@ function generateTextReport(
|
|||||||
if (intakes?.length) {
|
if (intakes?.length) {
|
||||||
lines.push(h3(t("report.docIntakeSchedule")));
|
lines.push(h3(t("report.docIntakeSchedule")));
|
||||||
for (const intake of intakes) {
|
for (const intake of intakes) {
|
||||||
let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`;
|
let entry = getUsageText(med, intake.usage, t);
|
||||||
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
||||||
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
||||||
if ("takenBy" in intake && intake.takenBy)
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
@@ -382,6 +430,9 @@ function generateTextReport(
|
|||||||
lines.push(h3(t("report.docIntakeHistory")));
|
lines.push(h3(t("report.docIntakeHistory")));
|
||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
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.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
||||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
||||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
||||||
@@ -394,7 +445,7 @@ function generateTextReport(
|
|||||||
if (data.refills.length > 0) {
|
if (data.refills.length > 0) {
|
||||||
lines.push(h3(t("report.docRefillHistory")));
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
}
|
}
|
||||||
@@ -478,22 +529,24 @@ function buildPrintHtml(
|
|||||||
for (const med of meds) {
|
for (const med of meds) {
|
||||||
const data = reportData[med.id];
|
const data = reportData[med.id];
|
||||||
const intakes = med.intakes ?? med.blisters;
|
const intakes = med.intakes ?? med.blisters;
|
||||||
|
const displayName = getMedDisplayName(med);
|
||||||
const title = med.isObsolete
|
const title = med.isObsolete
|
||||||
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
|
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
|
||||||
: escHtml(med.name);
|
: escHtml(displayName);
|
||||||
|
|
||||||
let s = `<div class="med-section">`;
|
let s = `<div class="med-section">`;
|
||||||
const imgDataUrl = imageMap[med.id];
|
const imgDataUrl = imageMap[med.id];
|
||||||
|
|
||||||
// Title with generic name subtitle
|
// Title with generic name subtitle
|
||||||
s += `<h2>${title}</h2>`;
|
s += `<h2>${title}</h2>`;
|
||||||
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
|
if (med.name && med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
|
||||||
|
|
||||||
// Build general info table rows
|
// Build general info table rows
|
||||||
const generalRows: string[] = [];
|
const generalRows: string[] = [];
|
||||||
generalRows.push(
|
if (med.name)
|
||||||
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
|
generalRows.push(
|
||||||
);
|
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
|
||||||
|
);
|
||||||
if (med.genericName)
|
if (med.genericName)
|
||||||
generalRows.push(
|
generalRows.push(
|
||||||
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
|
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
|
||||||
@@ -516,7 +569,7 @@ function buildPrintHtml(
|
|||||||
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
|
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
|
||||||
|
|
||||||
if (imgDataUrl) {
|
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>`;
|
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(displayName)}" /><div class="med-overview-info">${generalTable}</div></div>`;
|
||||||
} else {
|
} else {
|
||||||
s += generalTable;
|
s += generalTable;
|
||||||
}
|
}
|
||||||
@@ -524,18 +577,18 @@ function buildPrintHtml(
|
|||||||
// Package / Stock
|
// Package / Stock
|
||||||
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||||
s += `<table><tbody>`;
|
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>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
|
||||||
if (med.packageType === "blister") {
|
if (!isAmountBasedPackageType(med.packageType)) {
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
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.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
||||||
if (med.looseTablets > 0)
|
if (med.looseTablets > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</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>`;
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||||
if (med.pillWeightMg)
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||||
if (med.expiryDate)
|
if (med.expiryDate)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||||
@@ -552,7 +605,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const intake of filteredPrintIntakes) {
|
for (const intake of filteredPrintIntakes) {
|
||||||
let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`;
|
let entry = escHtml(getUsageText(med, intake.usage, t));
|
||||||
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
||||||
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
||||||
if ("takenBy" in intake && intake.takenBy)
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
@@ -580,6 +633,9 @@ function buildPrintHtml(
|
|||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
s += `<table><tbody>`;
|
s += `<table><tbody>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
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)
|
if (data.dosesDismissed > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||||
if (data.firstDoseAt)
|
if (data.firstDoseAt)
|
||||||
@@ -596,7 +652,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`;
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
s += `<li>${entry}</li>`;
|
s += `<li>${entry}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function ShareDialog({
|
|||||||
const closeLabel = t("common.close");
|
const closeLabel = t("common.close");
|
||||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||||
|
|
||||||
|
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,13 +52,15 @@ export function ShareDialog({
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content share-dialog-modal"
|
className="modal-content share-dialog-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -124,8 +128,12 @@ export function ShareDialog({
|
|||||||
return (
|
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}
|
||||||
@@ -135,8 +143,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>
|
||||||
@@ -145,7 +157,7 @@ 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")}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// 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 { getMedDisplayName, getMedTotal, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { isDoseDismissed } from "../utils/schedule";
|
import { isDoseDismissed } from "../utils/schedule";
|
||||||
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
import { loadCollapsedDaysFromStorage } from "../utils/storage";
|
||||||
@@ -18,11 +21,20 @@ import { MedicationAvatar } from "./MedicationAvatar";
|
|||||||
function getStockStatus(
|
function getStockStatus(
|
||||||
daysLeft: number | null,
|
daysLeft: number | null,
|
||||||
medsLeft: number,
|
medsLeft: number,
|
||||||
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
|
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; criticalStockDays: number },
|
||||||
|
packageType?: string
|
||||||
) {
|
) {
|
||||||
|
if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" };
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||||
if (daysLeft <= thresholds.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
|
if (isLiquidContainerPackageType(packageType)) {
|
||||||
|
const lowDays = Math.max(1, Math.floor(thresholds.criticalStockDays));
|
||||||
|
const criticalDays = Math.max(1, Math.ceil(lowDays / 2));
|
||||||
|
if (daysLeft <= criticalDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
|
if (daysLeft <= lowDays) return { className: "warning", label: "status.lowStock" };
|
||||||
|
return { className: "success", label: "status.normal" };
|
||||||
|
}
|
||||||
|
if (daysLeft <= thresholds.criticalStockDays) return { className: "danger", label: "status.criticalStock" };
|
||||||
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
|
||||||
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
|
||||||
return { className: "success", label: "status.normal" };
|
return { className: "success", label: "status.normal" };
|
||||||
@@ -41,6 +53,100 @@ export function SharedSchedule() {
|
|||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
|
|
||||||
|
const isLiquidContainerMed = (med: SharedScheduleData["medications"][number] | undefined) =>
|
||||||
|
isLiquidContainerPackageType(med?.packageType);
|
||||||
|
|
||||||
|
const convertLiquidUsageToMl = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): number => {
|
||||||
|
if (unit === "tsp") return usage * 5;
|
||||||
|
if (unit === "tbsp") return usage * 15;
|
||||||
|
return usage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertUsageForStock = (
|
||||||
|
usage: number,
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
unit: "ml" | "tsp" | "tbsp" | null | undefined
|
||||||
|
): number => {
|
||||||
|
if (isTubePackageType(med?.packageType)) return 0;
|
||||||
|
if (!isLiquidContainerMed(med)) return usage;
|
||||||
|
return convertLiquidUsageToMl(usage, unit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (value: number) => {
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
return String(rounded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||||
|
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||||
|
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||||
|
return t("form.packageAmountUnitMl");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLiquidUsageLabel = (usage: number, unit: "ml" | "tsp" | "tbsp" | null | undefined): string => {
|
||||||
|
const normalizedUsage = Number(usage);
|
||||||
|
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
|
||||||
|
return `0 ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === "ml" || unit == null) {
|
||||||
|
return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
|
||||||
|
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDoseUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
usage: number,
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
return formatLiquidUsageLabel(usage, intakeUnit);
|
||||||
|
}
|
||||||
|
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTotalUsageLabel = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
total: number,
|
||||||
|
doses?: Array<{ usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }>
|
||||||
|
) => {
|
||||||
|
if (isLiquidContainerMed(med)) {
|
||||||
|
if (doses && doses.length > 0) {
|
||||||
|
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
|
||||||
|
if (normalizedDoses.length > 0) {
|
||||||
|
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
|
||||||
|
if (allUnits.size === 1) {
|
||||||
|
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
|
||||||
|
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
|
||||||
|
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalMl = normalizedDoses.reduce(
|
||||||
|
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t("common.pillsTotal", { count: total });
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||||
|
|
||||||
|
const getVisibleStockStatus = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
status: { className: string; label: string } | null
|
||||||
|
) => (shouldHideNoScheduleStatusForTube(med, status) ? null : status);
|
||||||
|
|
||||||
// Theme preference: light, dark, or system
|
// Theme preference: light, dark, or system
|
||||||
type ThemePreference = "light" | "dark" | "system";
|
type ThemePreference = "light" | "dark" | "system";
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => {
|
||||||
@@ -149,15 +255,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(() => {
|
||||||
@@ -314,6 +412,7 @@ export function SharedSchedule() {
|
|||||||
when: number;
|
when: number;
|
||||||
medName: string;
|
medName: string;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
timeStr: string;
|
timeStr: string;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
takenBy: string | null; // Per-intake takenBy (single person or null)
|
takenBy: string | null; // Per-intake takenBy (single person or null)
|
||||||
@@ -324,7 +423,12 @@ export function SharedSchedule() {
|
|||||||
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
// Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
|
||||||
const intakes =
|
const intakes =
|
||||||
med.intakes ||
|
med.intakes ||
|
||||||
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
|
med.blisters.map((b) => ({
|
||||||
|
...b,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
intakes.forEach((intake, intakeIdx) => {
|
intakes.forEach((intake, intakeIdx) => {
|
||||||
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
|
||||||
@@ -348,8 +452,9 @@ export function SharedSchedule() {
|
|||||||
doses.push({
|
doses.push({
|
||||||
id: doseId,
|
id: doseId,
|
||||||
when: t,
|
when: t,
|
||||||
medName: med.name,
|
medName: getMedDisplayName(med),
|
||||||
usage: intake.usage,
|
usage: intake.usage,
|
||||||
|
intakeUnit: intake.intakeUnit ?? null,
|
||||||
isPast,
|
isPast,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
|
||||||
@@ -435,8 +540,14 @@ export function SharedSchedule() {
|
|||||||
const depletion: Record<string, number | null> = {};
|
const depletion: Record<string, number | null> = {};
|
||||||
|
|
||||||
for (const med of data.medications) {
|
for (const med of data.medications) {
|
||||||
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
|
const intakes =
|
||||||
const blisters = med.blisters;
|
med.intakes ||
|
||||||
|
med.blisters.map((b) => ({
|
||||||
|
...b,
|
||||||
|
intakeUnit: null,
|
||||||
|
takenBy: null as string | null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// Count unique people from all intakes (for per-intake takenBy)
|
// Count unique people from all intakes (for per-intake takenBy)
|
||||||
const uniquePeople = new Set<string>();
|
const uniquePeople = new Set<string>();
|
||||||
@@ -448,9 +559,9 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Calculate daily consumption rate accounting for per-intake takenBy
|
// Calculate daily consumption rate accounting for per-intake takenBy
|
||||||
let dailyRate = 0;
|
let dailyRate = 0;
|
||||||
blisters.forEach((s, idx) => {
|
intakes.forEach((intake) => {
|
||||||
const baseRate = s.every > 0 ? s.usage / s.every : 0;
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const intake = intakes[idx];
|
const baseRate = intake.every > 0 ? usageForStock / intake.every : 0;
|
||||||
if (intake?.takenBy) {
|
if (intake?.takenBy) {
|
||||||
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
dailyRate += baseRate; // Per-intake takenBy: 1 person
|
||||||
} else {
|
} else {
|
||||||
@@ -463,9 +574,10 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (calcMode === "automatic") {
|
if (calcMode === "automatic") {
|
||||||
// Time-based: every scheduled dose counts as consumed once its time has passed
|
// Time-based: every scheduled dose counts as consumed once its time has passed
|
||||||
blisters.forEach((s, blisterIdx) => {
|
intakes.forEach((intake, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const usageForStock = convertUsageForStock(intake.usage, med, intake.intakeUnit ?? "ml");
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const blisterStart = new Date(intake.start).getTime();
|
||||||
|
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||||
|
|
||||||
let effectiveStart: number;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
@@ -477,7 +589,6 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart)) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||||
@@ -487,7 +598,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
if (effectiveStart <= now) {
|
if (effectiveStart <= now) {
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
timeBasedConsumed = occurrences * usageForStock * peopleForThisIntake.length;
|
||||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
lastAutoConsumedDateMs = new Date(
|
lastAutoConsumedDateMs = new Date(
|
||||||
lastDoseTime.getFullYear(),
|
lastDoseTime.getFullYear(),
|
||||||
@@ -515,7 +626,7 @@ export function SharedSchedule() {
|
|||||||
const bIdx = parseInt(parts[1], 10);
|
const bIdx = parseInt(parts[1], 10);
|
||||||
const timestamp = parseInt(parts[2], 10);
|
const timestamp = parseInt(parts[2], 10);
|
||||||
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
earlyTakenConsumed += s.usage;
|
earlyTakenConsumed += usageForStock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,8 +641,8 @@ export function SharedSchedule() {
|
|||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
const blisterIdx = parseInt(parts[1], 10);
|
||||||
const doseTimestamp = parseInt(parts[2], 10);
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
if (medId === med.id && blisters[blisterIdx]) {
|
if (medId === med.id && intakes[blisterIdx]) {
|
||||||
const blisterStartDate = new Date(blisters[blisterIdx].start);
|
const blisterStartDate = new Date(intakes[blisterIdx].start);
|
||||||
const blisterStartDateOnly = new Date(
|
const blisterStartDateOnly = new Date(
|
||||||
blisterStartDate.getFullYear(),
|
blisterStartDate.getFullYear(),
|
||||||
blisterStartDate.getMonth(),
|
blisterStartDate.getMonth(),
|
||||||
@@ -539,7 +650,11 @@ export function SharedSchedule() {
|
|||||||
).getTime();
|
).getTime();
|
||||||
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
|
||||||
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += convertUsageForStock(
|
||||||
|
intakes[blisterIdx].usage,
|
||||||
|
med,
|
||||||
|
intakes[blisterIdx].intakeUnit ?? "ml"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,8 +667,8 @@ export function SharedSchedule() {
|
|||||||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
|
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
|
||||||
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
|
||||||
|
|
||||||
coverage[med.name] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
|
coverage[getMedDisplayName(med)] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
|
||||||
depletion[med.name] = depletionMs;
|
depletion[getMedDisplayName(med)] = depletionMs;
|
||||||
}
|
}
|
||||||
return { coverageByMed: coverage, depletionByMed: depletion };
|
return { coverageByMed: coverage, depletionByMed: depletion };
|
||||||
}, [data, takenDoses]);
|
}, [data, takenDoses]);
|
||||||
@@ -574,11 +689,13 @@ export function SharedSchedule() {
|
|||||||
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
|
||||||
const statuses = meds.map((item) => {
|
const statuses = meds.map((item) => {
|
||||||
const coverage = coverageByMed[item.medName];
|
const coverage = coverageByMed[item.medName];
|
||||||
|
const med = data?.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
|
||||||
if (!coverage) return "success";
|
if (!coverage) return "success";
|
||||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
const rawStatus = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds, med?.packageType);
|
||||||
return status.className;
|
const status = getVisibleStockStatus(med, rawStatus);
|
||||||
|
return status?.className ?? "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
@@ -588,6 +705,11 @@ export function SharedSchedule() {
|
|||||||
const showStock = data?.shareStockStatus !== false;
|
const showStock = data?.shareStockStatus !== false;
|
||||||
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||||
|
|
||||||
|
const renderDoseUsage = (
|
||||||
|
med: SharedScheduleData["medications"][number] | undefined,
|
||||||
|
dose: { usage: number; intakeUnit?: "ml" | "tsp" | "tbsp" | null }
|
||||||
|
) => formatDoseUsageLabel(med, dose.usage, dose.intakeUnit);
|
||||||
|
|
||||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||||
function isDoseIdDone(doseId: string): boolean {
|
function isDoseIdDone(doseId: string): boolean {
|
||||||
if (takenDoses.has(doseId)) return true;
|
if (takenDoses.has(doseId)) return true;
|
||||||
@@ -751,7 +873,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Count missed doses that are NOT dismissed (for warning icon)
|
// Count missed doses that are NOT dismissed (for warning icon)
|
||||||
const missedNotDismissedCount = day.meds.reduce((count, item) => {
|
const missedNotDismissedCount = day.meds.reduce((count, item) => {
|
||||||
const med = data.medications.find((m) => m.name === item.medName);
|
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
||||||
return (
|
return (
|
||||||
count +
|
count +
|
||||||
@@ -805,7 +927,7 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const med = data.medications.find((m) => m.name === item.medName);
|
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
|
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
@@ -814,9 +936,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -830,10 +958,10 @@ export function SharedSchedule() {
|
|||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<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, getMedDisplayName(med))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -845,9 +973,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</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">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -858,9 +990,7 @@ 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">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -989,7 +1119,7 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const med = data.medications.find((m) => m.name === item.medName);
|
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
|
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
@@ -998,9 +1128,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1013,10 +1149,10 @@ export function SharedSchedule() {
|
|||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<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, getMedDisplayName(med))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1028,9 +1164,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</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">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1045,9 +1185,7 @@ 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">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1166,7 +1304,7 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const med = data.medications.find((m) => m.name === item.medName);
|
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
@@ -1174,9 +1312,15 @@ export function SharedSchedule() {
|
|||||||
? willBeOutOfStock
|
? willBeOutOfStock
|
||||||
? { className: "danger", label: "status.outOfStock" }
|
? { className: "danger", label: "status.outOfStock" }
|
||||||
: medCoverage
|
: medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
|
? getStockStatus(
|
||||||
|
medCoverage.daysLeft,
|
||||||
|
medCoverage.medsLeft,
|
||||||
|
stockThresholds,
|
||||||
|
med?.packageType
|
||||||
|
)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
const visibleStatus = getVisibleStockStatus(med, status);
|
||||||
|
|
||||||
const itemDoseIds = item.doses.map((d) => d.id);
|
const itemDoseIds = item.doses.map((d) => d.id);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1189,10 +1333,10 @@ export function SharedSchedule() {
|
|||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<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, getMedDisplayName(med))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1204,9 +1348,13 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</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">
|
||||||
{status && (
|
{formatTotalUsageLabel(med, item.total, item.doses)}
|
||||||
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
|
</span>
|
||||||
|
{visibleStatus && (
|
||||||
|
<span className={`status-chip small ${visibleStatus.className}`}>
|
||||||
|
{t(visibleStatus.label)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1217,9 +1365,7 @@ 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">
|
<span className="dose-usage-main">{renderDoseUsage(med, dose)}</span>
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
</span>
|
|
||||||
{med?.pillWeightMg && (
|
{med?.pillWeightMg && (
|
||||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1281,7 +1427,7 @@ export function SharedSchedule() {
|
|||||||
className="lightbox-overlay"
|
className="lightbox-overlay"
|
||||||
onClick={closeLightbox}
|
onClick={closeLightbox}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") closeLightbox();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="lightbox-close" onClick={closeLightbox}>
|
<button className="lightbox-close" onClick={closeLightbox}>
|
||||||
@@ -1292,7 +1438,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) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
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 { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
||||||
import { formatNumber } from "../utils";
|
import { formatNumber } from "../utils";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
@@ -31,6 +32,8 @@ 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));
|
||||||
@@ -40,13 +43,15 @@ export function UserFilterModal({
|
|||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content user-meds-modal"
|
className="modal-content user-meds-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
@@ -59,11 +64,11 @@ export function UserFilterModal({
|
|||||||
|
|
||||||
<div className="user-meds-list">
|
<div className="user-meds-list">
|
||||||
{userMeds.map((med) => {
|
{userMeds.map((med) => {
|
||||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
|
||||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||||
const status = medCoverage
|
const status = medCoverage
|
||||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||||
: getStockStatus(null, getMedTotal(med), settings);
|
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||||
const packageSize = getPackageSize(med);
|
const packageSize = getPackageSize(med);
|
||||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||||
|
|
||||||
@@ -92,19 +97,20 @@ export function UserFilterModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
|
||||||
<div className="user-med-info">
|
<div className="user-med-info">
|
||||||
<span className="user-med-name">{med.name}</span>
|
<span className="user-med-name">{getMedDisplayName(med)}</span>
|
||||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
{med.name && 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"})`}{" "}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -17,6 +17,7 @@ export type DoseInfo = {
|
|||||||
timeStr: string;
|
timeStr: string;
|
||||||
when: number;
|
when: number;
|
||||||
usage: number;
|
usage: number;
|
||||||
|
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||||
takenBy: string[];
|
takenBy: string[];
|
||||||
intakeRemindersEnabled: boolean;
|
intakeRemindersEnabled: boolean;
|
||||||
};
|
};
|
||||||
@@ -72,6 +73,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>;
|
||||||
@@ -127,7 +129,7 @@ export interface AppContextValue {
|
|||||||
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>;
|
||||||
@@ -212,7 +214,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 {
|
||||||
@@ -242,9 +254,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);
|
||||||
@@ -350,6 +385,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
(dayMeds: { medName: string; lastWhen: number }[]): "success" | "warning" | "danger" => {
|
||||||
const statuses = dayMeds.map((item) => {
|
const statuses = dayMeds.map((item) => {
|
||||||
const cov = coverageByMed[item.medName];
|
const cov = coverageByMed[item.medName];
|
||||||
|
const med = activeMeds.find((m) => m.name === item.medName || m.genericName === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
|
|
||||||
// Will be out of stock by this day?
|
// Will be out of stock by this day?
|
||||||
@@ -358,21 +394,15 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cov) return "success";
|
if (!cov) return "success";
|
||||||
const { daysLeft, medsLeft } = cov;
|
const status = getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, med?.packageType);
|
||||||
|
if (status.className === "danger") return "danger";
|
||||||
// Currently out of stock
|
if (status.className === "warning") return "warning";
|
||||||
if (medsLeft <= 0 || daysLeft === 0) return "danger";
|
|
||||||
// No schedule (can't calculate)
|
|
||||||
if (daysLeft === null) return "success";
|
|
||||||
// Low stock: < lowStockDays (warning)
|
|
||||||
if (daysLeft < settingsHook.settings.lowStockDays) return "warning";
|
|
||||||
// Normal/High stock
|
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
},
|
},
|
||||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
[coverageByMed, depletionByMed, activeMeds, stockThresholds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedSchedule = useMemo(() => {
|
const groupedSchedule = useMemo(() => {
|
||||||
@@ -405,6 +435,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
timeStr: event.timeStr,
|
timeStr: event.timeStr,
|
||||||
when: event.when,
|
when: event.when,
|
||||||
usage: event.usage,
|
usage: event.usage,
|
||||||
|
intakeUnit: event.intakeUnit ?? null,
|
||||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||||
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
intakeRemindersEnabled: event.intakeRemindersEnabled,
|
||||||
});
|
});
|
||||||
@@ -456,6 +487,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);
|
||||||
@@ -465,37 +500,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(() => {
|
||||||
setScheduleLightboxImage(imageUrl);
|
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
|
||||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
|
||||||
}, []);
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "imageLightbox") {
|
||||||
|
setShowImageLightbox(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageLightboxCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
|
const openScheduleLightbox = useCallback(
|
||||||
|
(imageUrl: string) => {
|
||||||
|
if (scheduleLightboxImage) return;
|
||||||
|
scheduleLightboxOpenedAtRef.current = Date.now();
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
setScheduleLightboxImage(imageUrl);
|
||||||
|
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||||
|
},
|
||||||
|
[scheduleLightboxImage]
|
||||||
|
);
|
||||||
|
|
||||||
const closeScheduleLightbox = useCallback(() => {
|
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(
|
||||||
setSelectedUser(person);
|
(person: string) => {
|
||||||
window.history.pushState({ modal: "userFilter", person }, "");
|
if (selectedUser === person) return;
|
||||||
}, []);
|
setSelectedUser(person);
|
||||||
|
window.history.pushState({ modal: "userFilter", person }, "");
|
||||||
|
},
|
||||||
|
[selectedUser]
|
||||||
|
);
|
||||||
|
|
||||||
const closeUserFilter = useCallback(() => {
|
const closeUserFilter = useCallback(() => {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
@@ -586,7 +662,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Get the response text first to handle non-JSON responses
|
// Get the response text first to handle non-JSON responses
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let data: { error?: string; message?: string; imported?: number } = {};
|
let data: {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
imported?:
|
||||||
|
| {
|
||||||
|
medications?: number;
|
||||||
|
doseHistory?: number;
|
||||||
|
refillHistory?: number;
|
||||||
|
shareLinks?: number;
|
||||||
|
}
|
||||||
|
| number;
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
data = text ? JSON.parse(text) : {};
|
data = text ? JSON.parse(text) : {};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -601,11 +688,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show success message in UI instead of browser alert
|
// Show success message in UI instead of browser alert
|
||||||
|
const importedCounts = typeof data.imported === "object" && data.imported !== null ? data.imported : null;
|
||||||
setImportResult({
|
setImportResult({
|
||||||
medications: data.imported?.medications || 0,
|
medications: importedCounts?.medications || 0,
|
||||||
doses: data.imported?.doseHistory || 0,
|
doses: importedCounts?.doseHistory || 0,
|
||||||
refills: data.imported?.refillHistory || 0,
|
refills: importedCounts?.refillHistory || 0,
|
||||||
shares: data.imported?.shareLinks || 0,
|
shares: importedCounts?.shareLinks || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload all data
|
// Reload all data
|
||||||
@@ -732,6 +820,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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user