Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+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" \
|
||||||
@@ -158,7 +164,7 @@ 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.
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +50,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -79,6 +79,8 @@ Thumbs.db
|
|||||||
.turbo/
|
.turbo/
|
||||||
.roo/
|
.roo/
|
||||||
.roomodes
|
.roomodes
|
||||||
|
.claude/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku
|
doku
|
||||||
|
plan
|
||||||
@@ -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-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-769%2F769-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
|
||||||
|
|
||||||
@@ -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;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1771164000000,
|
"when": 1771164000000,
|
||||||
"tag": "0009_add_medication_start_date",
|
"tag": "0009_add_medication_start_date",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771694832866,
|
||||||
|
"tag": "0010_mean_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+813
-156
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.13.0",
|
"version": "1.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,15 +32,17 @@
|
|||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.2",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.1",
|
"@biomejs/biome": "^2.4.4",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.3.0",
|
||||||
"@types/nodemailer": "^7.0.10",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"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
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+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 };
|
||||||
|
|||||||
@@ -72,6 +72,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")
|
||||||
});
|
});
|
||||||
@@ -364,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
scheduledTime: scheduledTimeIso,
|
scheduledTime: scheduledTimeIso,
|
||||||
takenAt: takenAtIso,
|
takenAt: takenAtIso,
|
||||||
markedBy: dose.markedBy,
|
markedBy: dose.markedBy,
|
||||||
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenByPerson: parsed.person,
|
takenByPerson: parsed.person,
|
||||||
};
|
};
|
||||||
@@ -625,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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";
|
||||||
@@ -10,6 +8,12 @@ import { doseTracking, medications, userSettings } from "../db/schema.js";
|
|||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_MIME_TYPES,
|
||||||
|
removeImageFiles,
|
||||||
|
streamToBuffer,
|
||||||
|
writeOptimizedImageSet,
|
||||||
|
} from "../utils/image-upload.js";
|
||||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||||
@@ -38,7 +42,7 @@ const medicationStartDateSchema = z
|
|||||||
|
|
||||||
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)
|
||||||
packageType: packageTypeSchema,
|
packageType: packageTypeSchema,
|
||||||
@@ -62,6 +66,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) => {
|
||||||
@@ -693,10 +701,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 +724,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 +770,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)
|
||||||
@@ -817,11 +826,12 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
const rawTakenAt = Number(dose.takenAt);
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
let takenAtMs: number;
|
||||||
? rawTakenAt < 1_000_000_000_000
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
? rawTakenAt * 1000
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
: rawTakenAt
|
} else {
|
||||||
: new Date(dose.takenAt).getTime();
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -876,11 +886,14 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
const peopleForThisIntake = intakePerson
|
let peopleForThisIntake: Array<string | null>;
|
||||||
? [intakePerson]
|
if (intakePerson) {
|
||||||
: fallbackPeople.length > 0
|
peopleForThisIntake = [intakePerson];
|
||||||
? fallbackPeople
|
} else if (fallbackPeople.length > 0) {
|
||||||
: [null];
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,10 +371,10 @@ ${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 })
|
||||||
.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 filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||||
if (filteredLowStock.length === 0) {
|
if (filteredLowStock.length === 0) {
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
@@ -641,10 +641,10 @@ ${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) {
|
||||||
return reply.status(400).send({ error: "No active medications to notify" });
|
return reply.status(400).send({ error: "No active medications to notify" });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+238
-36
@@ -85,6 +85,21 @@ type TestShoutrrrBody = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,7 +284,8 @@ 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);
|
||||||
@@ -467,6 +483,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 +491,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 +514,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 +547,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 +566,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 +606,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 +776,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 +816,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:
|
||||||
|
|||||||
+40
-12
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@@ -14,9 +14,6 @@ import {
|
|||||||
personTakesMedication,
|
personTakesMedication,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
// Share token validity: 1 year in milliseconds
|
|
||||||
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Validation Schemas
|
// Validation Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -25,6 +22,11 @@ const createShareSchema = z.object({
|
|||||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function maskToken(token: string): string {
|
||||||
|
if (token.length <= 8) return token;
|
||||||
|
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -54,6 +56,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Find share token
|
// Find share token
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
if (!share) {
|
||||||
|
request.log.warn(`[Share] Invalid share token requested: ${maskToken(token)}`);
|
||||||
return reply.status(404).send({
|
return reply.status(404).send({
|
||||||
error: "Share link not found",
|
error: "Share link not found",
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -62,6 +65,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Check if token has expired
|
// Check if token has expired
|
||||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
|
request.log.warn(
|
||||||
|
`[Share] Expired token requested: ${maskToken(token)} (owner=${share.userId}, takenBy=${share.takenBy})`
|
||||||
|
);
|
||||||
// Get the username of the owner to show in the expired message
|
// Get the username of the owner to show in the expired message
|
||||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
return reply.status(410).send({
|
return reply.status(410).send({
|
||||||
@@ -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,114 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
|
const intakeDate = intake.intakeTime;
|
||||||
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
|
if (intake.takenBy) {
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
|
}
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoMarkDueIntakesAsTaken(
|
||||||
|
settings: UserSettings & { userId: number },
|
||||||
|
rows: (typeof medications.$inferSelect)[],
|
||||||
|
locale: string,
|
||||||
|
tz: string,
|
||||||
|
logger: ServiceLogger
|
||||||
|
): Promise<number> {
|
||||||
|
if (settings.stockCalculationMode !== "automatic") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const existingToday = await db
|
||||||
|
.select({ doseId: doseTracking.doseId })
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, settings.userId),
|
||||||
|
gte(doseTracking.takenAt, todayStart),
|
||||||
|
lte(doseTracking.takenAt, todayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const med of rows) {
|
||||||
|
if (med.isObsolete) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
if (intakes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const 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[],
|
||||||
@@ -246,6 +354,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;
|
||||||
@@ -262,11 +381,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Get all medications with intake reminders enabled for this user
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(medications)
|
|
||||||
.where(eq(medications.userId, settings.userId))
|
|
||||||
.orderBy(medications.id);
|
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
@@ -280,9 +394,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
const locale = getDateLocale(language);
|
|
||||||
const tz = getTimezone();
|
|
||||||
|
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
@@ -305,9 +416,10 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
// 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)
|
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
|
||||||
@@ -328,7 +440,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 +467,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,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -40,6 +40,56 @@ function escapeHtml(text: string): string {
|
|||||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
|
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||||
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function ensureReminderLocksDir(): void {
|
||||||
|
if (!existsSync(reminderLocksDir)) {
|
||||||
|
mkdirSync(reminderLocksDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireReminderSendLock(lockKey: string): string | null {
|
||||||
|
ensureReminderLocksDir();
|
||||||
|
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
|
||||||
|
|
||||||
|
const tryCreateLock = (): boolean => {
|
||||||
|
try {
|
||||||
|
const fd = openSync(lockFilePath, "wx");
|
||||||
|
closeSync(fd);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(lockFilePath);
|
||||||
|
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; lock acquisition fails safely
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||||
|
if (!lockFilePath) return;
|
||||||
|
try {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
} catch {
|
||||||
|
// ignore release errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
@@ -167,11 +217,12 @@ async function getMedicationsNeedingReminder(
|
|||||||
}
|
}
|
||||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
const rawTakenAt = Number(dose.takenAt);
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
let takenAtMs: number;
|
||||||
? rawTakenAt < 1_000_000_000_000
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
? rawTakenAt * 1000
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
: rawTakenAt
|
} else {
|
||||||
: new Date(dose.takenAt).getTime();
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +267,14 @@ async function getMedicationsNeedingReminder(
|
|||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null];
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intakePerson) {
|
||||||
|
peopleForThisIntake = [intakePerson];
|
||||||
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
@@ -509,6 +567,15 @@ ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDaily
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
|
||||||
|
// Track stock-scheduler daily execution separately from intake updates.
|
||||||
|
// This prevents intake reminders from suppressing stock catch-up after restarts.
|
||||||
|
const state = loadReminderState();
|
||||||
|
const today = getTodayInTimezone();
|
||||||
|
saveReminderState({
|
||||||
|
...state,
|
||||||
|
lastStockSchedulerCheckDate: today,
|
||||||
|
});
|
||||||
|
|
||||||
// Get all user settings to iterate over each user
|
// Get all user settings to iterate over each user
|
||||||
const allUserSettings = await getAllUserSettings();
|
const allUserSettings = await getAllUserSettings();
|
||||||
|
|
||||||
@@ -557,166 +624,213 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
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>
|
||||||
@@ -756,80 +870,103 @@ 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({
|
await transporter.sendMail({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: settings.notificationEmail!,
|
to: settings.notificationEmail!,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
emailSuccess = true;
|
emailSuccess = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
|
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);
|
||||||
@@ -854,6 +991,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)
|
||||||
@@ -861,9 +1003,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}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,9 +1016,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",
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -867,7 +868,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 +1288,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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -483,6 +487,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 +510,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 +530,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
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# Agent Memory Notes
|
||||||
|
|
||||||
|
Purpose: persistent agent work memory to survive context loss.
|
||||||
|
|
||||||
|
## Usage Rules
|
||||||
|
|
||||||
|
- Update this file during and after meaningful work.
|
||||||
|
- Record decisions, touched files, constraints, and unresolved follow-ups.
|
||||||
|
- Keep entries concise and chronological.
|
||||||
|
|
||||||
|
## How to maintain (1-minute template)
|
||||||
|
|
||||||
|
Use this block for each meaningful task:
|
||||||
|
|
||||||
|
```md
|
||||||
|
### YYYY-MM-DD
|
||||||
|
|
||||||
|
- 🧩 Task:
|
||||||
|
- ✅ Decisions:
|
||||||
|
- 📁 Files touched:
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
### 2026-02-27 (split-and-ship all pending local changes)
|
||||||
|
|
||||||
|
- 🧩 Task: Split one large local working tree into coherent PRs and merge all to `main` end-to-end.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Created and merged 4 PRs to keep scopes reviewable while ensuring all pending changes were shipped.
|
||||||
|
- PR mapping:
|
||||||
|
- #334 `feat/form-login-enabled` (Issue #309)
|
||||||
|
- #336 `chore/improve-logging` (Issue #335)
|
||||||
|
- #339 `fix/typescript-strictness-react19` (Issue #337)
|
||||||
|
- #341 `chore/dependabot-agent-governance` (Issue #340)
|
||||||
|
- For PR #341, required checks were initially skipped by path filtering; added minimal no-op backend/frontend comment touches so required checks executed and merge satisfied ruleset.
|
||||||
|
- Verified linked project items for issues `#309`, `#335`, `#337`, `#340` are `Done`.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- All changed files were fully distributed across PRs and merged.
|
||||||
|
- Mandatory reporting updated: `doku/memory_notes.md`, `doku/report.md`.
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- None pending from this split/merge task.
|
||||||
|
|
||||||
|
### 2026-02-27 (pre-PR gate validation for `chore/dependabot-agent-governance`)
|
||||||
|
|
||||||
|
- 🧩 Task: Validate minimal relevant local non-interactive checks for governance/config/docs changes.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Confirmed changed scope with `git status --short` and validated only listed files.
|
||||||
|
- Ran repo-defined lint gate (`npm run lint`) to satisfy local pre-PR lint requirement.
|
||||||
|
- Ran parser-level YAML/frontmatter checks for changed `.yml` and agent markdown files.
|
||||||
|
- Ran a targeted `markdownlint-cli2` check; it reported many style errors, but this linter is not part of this repository's configured gate.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Local pre-PR gate for this scope is satisfied by configured checks (lint + syntax validation); optional markdown style cleanup can be handled in a separate docs-formatting pass.
|
||||||
|
|
||||||
|
### 2026-02-27 (PR3 local gate rerun after MedDetailModal test fix)
|
||||||
|
|
||||||
|
- 🧩 Task: Re-run PR3 local gate on `fix/typescript-strictness-react19` after `MedDetailModal` assertion fix.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Re-ran `frontend check` via `CI=true npm --prefix /Users/danielvolz/git/medassist/frontend run check`.
|
||||||
|
- Re-ran the same focused Vitest subset from prior gate run (12 files including `MedDetailModal.test.tsx`).
|
||||||
|
- Treated React `act(...)` warnings and JSDOM `scrollTo()` notices as non-blocking because all tests passed.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Pre-PR local gate for the requested frontend scope is now satisfied.
|
||||||
|
|
||||||
|
### 2026-02-27 (pre-PR gate validation for `fix/typescript-strictness-react19`)
|
||||||
|
|
||||||
|
- 🧩 Task: Validate minimal relevant local non-interactive frontend lint/tests for React 19 + TS strictness scope.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Ran only frontend checks relevant to the changed scope: `check` (Biome + `tsc --noEmit`) and targeted Vitest on changed test files.
|
||||||
|
- Treated React `act(...)` warnings and JSDOM `scrollTo` notices as non-blocking because they did not fail tests.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Gate is blocked by one failing test assertion in `src/test/components/MedDetailModal.test.tsx` expecting `undefined` where implementation currently passes `false` as second arg to `onSubmitRefill`.
|
||||||
|
|
||||||
|
### 2026-02-27
|
||||||
|
|
||||||
|
- 🧩 Task: Implement Issue #309 — Optionally disable form login when OIDC enabled
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Env var: `FORM_LOGIN_ENABLED` (not `LOCAL_AUTH_ENABLED` — "local" is ambiguous, "form login" matches the UI element)
|
||||||
|
- Renamed internal field `localAuthEnabled` → `formLoginEnabled` throughout for consistency
|
||||||
|
- Default `true` for backward compat
|
||||||
|
- First-user override: form login forced on when no users exist (needsSetup)
|
||||||
|
- Lockout guard: startup error when no login method available
|
||||||
|
- Mismatch warning: log when REGISTRATION_ENABLED=true but form login off
|
||||||
|
- No DB changes, no i18n changes, no README update
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `backend/src/plugins/env.ts` — added FORM_LOGIN_ENABLED + validation
|
||||||
|
- `backend/src/plugins/auth.ts` — renamed field + wired to env var + first-user override
|
||||||
|
- `backend/src/routes/auth.ts` — renamed guard references + error code
|
||||||
|
- `frontend/src/components/Auth.tsx` — renamed interface + conditionals
|
||||||
|
- `frontend/src/test/components/Auth.test.tsx` — renamed in mocks
|
||||||
|
- `frontend/src/test/components/AppHeader.test.tsx` — renamed in mocks
|
||||||
|
- `backend/src/test/auth.test.ts` — renamed env mock + assertion
|
||||||
|
- `.env.example` — documented new var
|
||||||
|
- 🔜 Follow-up: E2E tests for OIDC-only mode (delegate to @testing-manager)
|
||||||
|
|
||||||
|
### 2026-02-27 (pre-PR gate validation for chore/improve-logging)
|
||||||
|
|
||||||
|
- 🧩 Task: Validate local lint/tests for branch `chore/improve-logging` on changed logging/nginx/backend-route files.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Ran minimal relevant non-interactive checks only: backend lint, frontend lint, and targeted backend route test file (`e2e-routes.test.ts`).
|
||||||
|
- No additional broad suites were executed to keep scope minimal.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Frontend lint still reports one warning in `frontend/src/components/MedicationAvatar.tsx` (`useExhaustiveDependencies`, extra dependency `imageUrl`).
|
||||||
|
- Pre-PR gate is not clean until this lint warning is resolved.
|
||||||
|
|
||||||
|
### 2026-02-26
|
||||||
|
|
||||||
|
- Added mandatory memory/report persistence rules to `.github/copilot-instructions.md` and `AGENTS.md`.
|
||||||
|
- Removed obsolete mandatory persistence rule for `doku/APP_BEHAVIOR.md` from `AGENTS.md`.
|
||||||
|
- Created `doku/memory_notes.md` and `doku/report.md` as the new required persistence/reporting files.
|
||||||
|
|
||||||
|
### 2026-02-26 — Logging Implementation Plan
|
||||||
|
|
||||||
|
- 🧩 Task: Create implementation plan to fix noisy logging (nginx 5s polling spam, missing timestamps, unfilterable levels).
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Use Fastify per-route `logLevel` option (not `disableRequestLogging`) to suppress health/polling request logs.
|
||||||
|
- Suppress `GET /doses/taken` and `GET /health` at `info` level (visible at `debug`).
|
||||||
|
- Add separate nginx location blocks for polling paths with `access_log off` at `info` level.
|
||||||
|
- Add ISO timestamps to startup logger (`backend/src/utils/logger.ts`).
|
||||||
|
- Add `pino-pretty` as devDependency for human-readable dev logs.
|
||||||
|
- Use nginx `log_format timed` with `$time_iso8601`.
|
||||||
|
- 📁 Files touched: `plan/feature-structured-logging-1.md` (created).
|
||||||
|
- 🔜 Follow-up: Implement the plan (5 phases, 18 tasks).
|
||||||
|
|
||||||
|
### 2026-02-26 — Logging Plan Implementation (complete)
|
||||||
|
|
||||||
|
- 🧩 Task: Implement all 5 phases of the structured logging plan.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Phase 1: Added `logLevel: 'warn'` to `GET /health`, `logLevel: 'debug'` to `GET /doses/taken` and `GET /share/:token/doses` — suppresses Pino automatic request logs at `info` level.
|
||||||
|
- Phase 2: Updated startup logger (`backend/src/utils/logger.ts`) to prepend `[ISO timestamp] [LEVEL]` prefix. Added `pino-pretty` devDependency with transport config active only when `NODE_ENV !== 'production' && !== 'test'`.
|
||||||
|
- Phase 3+4: nginx.conf now has dedicated location blocks for polling endpoints using `${NGINX_POLLING_LOG}` variable. `nginx-entrypoint.sh` differentiates `debug` (all logs) / `info` (polling suppressed) / `warn+` (all suppressed). Added `log_format timed` with ISO timestamps.
|
||||||
|
- Phase 5: Updated `.env.example` and `README.md` with detailed LOG_LEVEL behavior descriptions.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `backend/src/routes/health.ts` — logLevel: 'warn'
|
||||||
|
- `backend/src/routes/doses.ts` — logLevel: 'debug' on GET /doses/taken and GET /share/:token/doses
|
||||||
|
- `backend/src/utils/logger.ts` — ISO timestamps on all startup log messages
|
||||||
|
- `backend/src/index.ts` — pino-pretty transport for dev mode
|
||||||
|
- `backend/package.json` — added pino-pretty devDependency
|
||||||
|
- `frontend/nginx.conf` — polling location blocks, log_format timed
|
||||||
|
- `frontend/nginx-entrypoint.sh` — 3-tier LOG_LEVEL logic (debug/info/warn+)
|
||||||
|
- `.env.example` — expanded LOG_LEVEL docs
|
||||||
|
- `README.md` — expanded LOG_LEVEL description
|
||||||
|
- 🔜 Follow-up: Docker build + manual verification (TEST-004 through TEST-008). Hand off to @testing-manager for any automated test coverage.
|
||||||
|
|
||||||
|
### 2026-02-26 (follow-up)
|
||||||
|
|
||||||
|
- Added a short "How to maintain" template section to this file and to `doku/report.md`.
|
||||||
|
- Updated report entry so this follow-up is documented for user review.
|
||||||
|
|
||||||
|
### 2026-02-26 (emoji template follow-up)
|
||||||
|
|
||||||
|
- Added emoji-based label conventions for faster scanning in this file template.
|
||||||
|
- Updated `doku/report.md` template to match the same emoji convention.
|
||||||
|
|
||||||
|
### 2026-02-26 (testing-manager instruction hardening)
|
||||||
|
|
||||||
|
- 🧩 Task: Strengthen `testing-manager` agent instructions for lint gates, real/reliable tests, and current test setup commands.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Added hard lint gate: all errors and simple/fixable warnings must be resolved before PR-ready handoff.
|
||||||
|
- Added explicit anti-fake-test rules and validity checklist to enforce real functional verification and regression safety.
|
||||||
|
- Updated backend/frontend Vitest commands to non-watch CI-safe `test:run` usage and aligned Playwright examples.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `.github/agents/testing-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Keep this instruction set mirrored if additional testing policy docs are introduced later.
|
||||||
|
|
||||||
|
### 2026-02-26 (pre-PR local quality gate clarification)
|
||||||
|
|
||||||
|
- 🧩 Task: Clarify that PRs must not be created before local lint/tests are green.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Added explicit rule: before PR creation, all lint errors and relevant tests must pass locally.
|
||||||
|
- Added explicit rule: no CI-first failures; broken behavior must reproduce and be fixed locally before handoff.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `.github/agents/testing-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Apply same wording to other governance docs only if requested.
|
||||||
|
|
||||||
|
### 2026-02-26 (release-manager local gate alignment)
|
||||||
|
|
||||||
|
- 🧩 Task: Apply the same pre-PR local lint/test gate policy to `release-manager` instructions.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Added explicit pre-PR local quality gate requirement to `release-manager` critical rules.
|
||||||
|
- Added explicit no CI-first-failure policy for release orchestration.
|
||||||
|
- Updated PR workflow steps to require local gate confirmation before push/PR creation.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `.github/agents/release-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Keep both manager agents (`testing-manager`, `release-manager`) aligned on this gate language.
|
||||||
|
|
||||||
|
### 2026-02-26 (React 19 upgrade best-practice clarification)
|
||||||
|
|
||||||
|
- 🧩 Task: Validate and refine the React 19 upgrade plan with official guidance.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Keep `@types/react` and `@types/react-dom`, but bump both to `^19.x` during the React upgrade.
|
||||||
|
- Do not force `useContext` to `use()` migration in the upgrade PR; only fix what is required for compatibility.
|
||||||
|
- Keep strict scope boundary: version upgrade only; adopt new React 19 features in separate follow-up PRs.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- When implementation starts, apply the same scope boundary in commit and PR structure.
|
||||||
|
|
||||||
|
### 2026-02-26 (React 19 implementation)
|
||||||
|
|
||||||
|
- 🧩 Task: Implement the scoped React 19 dependency upgrade.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Upgraded `react`/`react-dom` to `^19.2.0`.
|
||||||
|
- Kept `@types/react` and `@types/react-dom` and upgraded both to `^19.2.2`.
|
||||||
|
- Did not include optional API migrations (`useContext` to `use()`, Actions APIs, RSC changes).
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/package.json`
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Run local install/lint/check in a dedicated testing handoff to validate full dependency tree behavior.
|
||||||
|
|
||||||
|
### 2026-02-26 (testing handoff run for React 19 upgrade)
|
||||||
|
|
||||||
|
- 🧩 Task: Execute frontend lint/check/relevant tests and apply only mandatory compatibility fixes.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Fixed only strict compatibility/type issues in touched tests (`ics`, `schedule`, `MobileEditModal`) without feature migration.
|
||||||
|
- Did not expand scope into broad unrelated test refactors.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/src/test/utils/ics.test.ts`
|
||||||
|
- `frontend/src/test/utils/schedule.test.ts`
|
||||||
|
- `frontend/src/test/components/MobileEditModal.test.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- `frontend check` still blocked by unrelated `MedDetailModal.test.tsx` prop-shape mismatches (`usePrescriptionRefill`, `onUsePrescriptionRefillChange`, and `RefillEntry` field changes).
|
||||||
|
- Existing lint warning remains in `frontend/src/components/MedicationAvatar.tsx` (`useExhaustiveDependencies`).
|
||||||
|
|
||||||
|
### 2026-02-26 (blocker follow-up: lint fix + testing-manager handoff)
|
||||||
|
|
||||||
|
- 🧩 Task: Remove remaining lint warning and prepare formal handoff for out-of-scope MedDetailModal test drift.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Fixed `MedicationAvatar` warning by tracking previous `imageUrl` via ref in effect logic.
|
||||||
|
- Kept `MedDetailModal.test.tsx` changes out of this implementation due testing ownership boundary and prepared explicit handoff content instead.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/src/components/MedicationAvatar.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- `@testing-manager` should align `MedDetailModal` tests with current `MedDetailModalProps` (`usePrescriptionRefill`, `onUsePrescriptionRefillChange`) and `RefillEntry` shape (`refillDate`, `loosePillsAdded`).
|
||||||
|
|
||||||
|
### 2026-02-26 (automatic delegation preference applied)
|
||||||
|
|
||||||
|
- 🧩 Task: Apply user preference to delegate testing work automatically without additional confirmation prompts.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Hand off residual test/type drift work to `@testing-manager` immediately when detected.
|
||||||
|
- Do not pause for approval before delegation unless there is a blocking ambiguity.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Keep this delegation style for future testing ownership boundaries.
|
||||||
|
|
||||||
|
### 2026-02-26 (continued type-fix sweep to green frontend check)
|
||||||
|
|
||||||
|
- 🧩 Task: Continue and clear remaining `frontend check` blockers after delegated MedDetailModal fixes.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Applied minimal compatibility fixes in production files only where type/lint failed (`MobileEditModal`, `SharedSchedule`, `AppContext`, `dashboard-helpers`, `DashboardPage`, `stock.ts`).
|
||||||
|
- Applied fixture-only updates in tests for new required `Medication`/`StockThresholds` shapes and minor mock typing issues.
|
||||||
|
- Kept scope to type/lint compatibility; no feature behavior migration.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/src/components/MobileEditModal.tsx`
|
||||||
|
- `frontend/src/components/SharedSchedule.tsx`
|
||||||
|
- `frontend/src/context/AppContext.tsx`
|
||||||
|
- `frontend/src/pages/dashboard-helpers.ts`
|
||||||
|
- `frontend/src/pages/DashboardPage.tsx`
|
||||||
|
- `frontend/src/utils/stock.ts`
|
||||||
|
- `frontend/src/test/setup.ts`
|
||||||
|
- `frontend/src/test/components/Lightbox.test.tsx`
|
||||||
|
- `frontend/src/test/components/UserFilterModal.test.tsx`
|
||||||
|
- `frontend/src/test/context/AppContext.test.tsx`
|
||||||
|
- `frontend/src/test/hooks/useMedications.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useRefill.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useSettings.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useShare.test.ts`
|
||||||
|
- `frontend/src/test/utils/formatters.test.ts`
|
||||||
|
- `frontend/src/test/utils/schedule.test.ts`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- `frontend check` is now green.
|
||||||
|
- Focused tests pass; remaining broader suite execution can be done as separate validation step if requested.
|
||||||
|
|
||||||
|
### 2026-02-26 (npm EINTEGRITY fix)
|
||||||
|
|
||||||
|
- 🧩 Task: Resolve npm tarball corruption/integrity install failure after React 19 lockfile update.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Verified official registry integrity values with `npm view` and corrected lockfile hashes.
|
||||||
|
- Did not change versions; only fixed integrity metadata for `@types/react@19.2.2` and `@types/react-dom@19.2.2`.
|
||||||
|
|
||||||
|
### 2026-02-26 (dependency update automation)
|
||||||
|
|
||||||
|
- 🧩 Task: Implement automatic dependency update flow with safe merge policy.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Extended existing `.github/dependabot.yml` instead of replacing it.
|
||||||
|
- Added grouped minor/patch updates for root npm and GitHub Actions, plus scoped labels (`frontend`, `backend`, `root`).
|
||||||
|
- Added `.github/workflows/dependabot-automerge.yml` to enable auto-merge only for Dependabot npm/GitHub Actions patch+minor updates.
|
||||||
|
- Kept major updates manual by design.
|
||||||
|
- Synced docs in `README.md` and updated React badge to 19.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `.github/dependabot.yml`
|
||||||
|
- `.github/workflows/dependabot-automerge.yml`
|
||||||
|
- `README.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- If branch protection requires specific checks, ensure required status checks are set so auto-merge waits correctly.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- `npm ci` now succeeds cleanly.
|
||||||
|
|
||||||
|
### 2026-02-26 (npm deprecation warnings assessment)
|
||||||
|
|
||||||
|
- 🧩 Task: Assess reported npm deprecation warnings and identify real source/package owners.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Warnings are not from `frontend`; they originate in `backend` transitive dependencies.
|
||||||
|
- `@esbuild-kit/*` comes from `drizzle-kit@0.31.9` (currently latest).
|
||||||
|
- `node-domexception` comes via `@libsql/client -> node-fetch -> fetch-blob` (currently latest published chain).
|
||||||
|
- Treat as non-blocking upstream warnings for now (no local secure/functional regression).
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- Re-check on future dependency releases; warnings can be removed once upstream chains migrate.
|
||||||
|
|
||||||
|
### 2026-02-26 (MedDetailModal test type drift fix)
|
||||||
|
|
||||||
|
- 🧩 Task: Unblock the targeted `MedDetailModal` test type drift after React 19 changes.
|
||||||
|
- ✅ Decisions:
|
||||||
|
- Kept scope minimal and test-only: updated `frontend/src/test/components/MedDetailModal.test.tsx` only.
|
||||||
|
- Added missing required props in `defaultProps`: `usePrescriptionRefill`, `onUsePrescriptionRefillChange`.
|
||||||
|
- Updated `RefillEntry` fixtures to current shape by replacing legacy fields with `refillDate` and `loosePillsAdded`.
|
||||||
|
- Did not run the targeted test command because the requested precondition (`npm run check` passing) is not met.
|
||||||
|
- 📁 Files touched:
|
||||||
|
- `frontend/src/test/components/MedDetailModal.test.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- 🔜 Follow-up/open points:
|
||||||
|
- `frontend check` remains blocked by unrelated TypeScript errors in other files (outside MedDetailModal test scope).
|
||||||
+478
@@ -0,0 +1,478 @@
|
|||||||
|
# Work Report
|
||||||
|
|
||||||
|
Purpose: user-facing summary of completed work.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
For each task, add:
|
||||||
|
|
||||||
|
- Date
|
||||||
|
- Scope
|
||||||
|
- What changed
|
||||||
|
- Files touched
|
||||||
|
- Follow-ups (if any)
|
||||||
|
|
||||||
|
## How to maintain (1-minute template)
|
||||||
|
|
||||||
|
```md
|
||||||
|
### YYYY-MM-DD
|
||||||
|
|
||||||
|
- **🧩 Scope**:
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
-
|
||||||
|
- **📁 Files touched**:
|
||||||
|
-
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
-
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
### 2026-02-27 (All pending local changes split and merged)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Take the full pending local change set, split into meaningful PRs, and merge everything into `main`.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Created and merged 4 PRs with full metadata (assignee, labels, project link, issue closure):
|
||||||
|
- PR `#334` (`feat/form-login-enabled`) closing Issue `#309`
|
||||||
|
- PR `#336` (`chore/improve-logging`) closing Issue `#335`
|
||||||
|
- PR `#339` (`fix/typescript-strictness-react19`) closing Issue `#337`
|
||||||
|
- PR `#341` (`chore/dependabot-agent-governance`) closing Issue `#340`
|
||||||
|
- Waited for CI on every PR and merged only with green required checks.
|
||||||
|
- Verified project board status for linked issues: all moved to `Done`.
|
||||||
|
- Resolved one merge-policy blocker on PR `#341` by adding minimal no-op backend/frontend touches so required checks were actually triggered (instead of skipped by path filtering).
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- Entire pending workspace delta was fully shipped across the 4 PRs above.
|
||||||
|
- Final bookkeeping updated in:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- None for this delivery request.
|
||||||
|
|
||||||
|
### 2026-02-27 (Local pre-PR gate validation: `chore/dependabot-agent-governance`)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Validate minimal relevant non-interactive local checks for changed governance/config/docs files.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Confirmed changed file scope with `git status --short`.
|
||||||
|
- Ran repo lint gate: `npm run lint` -> passed (backend Biome clean, frontend Biome clean).
|
||||||
|
- Ran YAML/frontmatter parser checks for changed `.yml` and agent markdown files -> passed.
|
||||||
|
- Ran targeted markdownlint (`npx -y markdownlint-cli2 ...`) -> failed with 379 markdown style issues (mostly line-length/table-spacing) across changed markdown files.
|
||||||
|
- Assessed markdownlint result as non-gating because this repository's configured local gate uses Biome on backend/frontend source files only.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Optional: run a dedicated markdown formatting/lint cleanup pass for agent/docs files in a separate scope.
|
||||||
|
|
||||||
|
### 2026-02-27 (PR3 local gate rerun: `fix/typescript-strictness-react19`)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Re-run requested local pre-PR frontend gate after `MedDetailModal` test fix.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Ran `CI=true npm --prefix /Users/danielvolz/git/medassist/frontend run check` -> passed.
|
||||||
|
- Re-ran the same focused Vitest subset (12 files) used previously -> passed.
|
||||||
|
- `src/test/components/MedDetailModal.test.tsx` now passes in that subset.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Requested local pre-PR gate is satisfied for frontend check + focused subset.
|
||||||
|
|
||||||
|
### 2026-02-27 (Local pre-PR gate validation: `fix/typescript-strictness-react19`)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Validate minimal relevant non-interactive frontend lint/tests for changed React 19 + TypeScript strictness files.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Ran `CI=true npm --prefix /Users/danielvolz/git/medassist/frontend run check` -> passed (Biome clean, `tsc --noEmit` clean).
|
||||||
|
- Ran focused Vitest only on changed test files:
|
||||||
|
- `src/test/components/Lightbox.test.tsx`
|
||||||
|
- `src/test/components/MedDetailModal.test.tsx`
|
||||||
|
- `src/test/components/MobileEditModal.test.tsx`
|
||||||
|
- `src/test/components/UserFilterModal.test.tsx`
|
||||||
|
- `src/test/context/AppContext.test.tsx`
|
||||||
|
- `src/test/hooks/useMedications.test.ts`
|
||||||
|
- `src/test/hooks/useRefill.test.ts`
|
||||||
|
- `src/test/hooks/useSettings.test.ts`
|
||||||
|
- `src/test/hooks/useShare.test.ts`
|
||||||
|
- `src/test/utils/formatters.test.ts`
|
||||||
|
- `src/test/utils/ics.test.ts`
|
||||||
|
- `src/test/utils/schedule.test.ts`
|
||||||
|
- Focused Vitest result: 11 files passed, 1 file failed (`MedDetailModal.test.tsx`, 1 failing assertion).
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Fix failing assertion in `src/test/components/MedDetailModal.test.tsx:329`:
|
||||||
|
- expected `onSubmitRefill(mockMedication.id, undefined)`
|
||||||
|
- received `onSubmitRefill(mockMedication.id, false)`
|
||||||
|
- Re-run the same focused Vitest command after the assertion/behavior is aligned.
|
||||||
|
|
||||||
|
### 2026-02-27
|
||||||
|
|
||||||
|
- **🧩 Scope**: Issue #309 — Optionally disable form login when OIDC enabled
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- New env var `FORM_LOGIN_ENABLED` (default `true`). Set to `false` to hide username/password form and only show the OIDC SSO button.
|
||||||
|
- Renamed all internal `localAuthEnabled` references to `formLoginEnabled` for clarity.
|
||||||
|
- Backend enforces lockout guard at startup — if no login method is available, the server refuses to start with a clear error message.
|
||||||
|
- Backend warns if `REGISTRATION_ENABLED=true` but form login is off (registration has no effect without the form).
|
||||||
|
- First-user setup override: even with `FORM_LOGIN_ENABLED=false`, the first admin account can always be created locally.
|
||||||
|
- All existing frontend/backend tests pass (55 frontend + 32 backend).
|
||||||
|
- Lint clean.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `backend/src/plugins/env.ts`
|
||||||
|
- `backend/src/plugins/auth.ts`
|
||||||
|
- `backend/src/routes/auth.ts`
|
||||||
|
- `frontend/src/components/Auth.tsx`
|
||||||
|
- `frontend/src/test/components/Auth.test.tsx`
|
||||||
|
- `frontend/src/test/components/AppHeader.test.tsx`
|
||||||
|
- `backend/src/test/auth.test.ts`
|
||||||
|
- `.env.example`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- E2E test for OIDC-only login flow → delegate to @testing-manager
|
||||||
|
- Consider adding backend unit test specifically for FORM_LOGIN_ENABLED=false scenarios
|
||||||
|
|
||||||
|
### 2026-02-27 (Local pre-PR gate validation: `chore/improve-logging`)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Validate minimal relevant non-interactive lint/tests for changed files:
|
||||||
|
- `.env.example`
|
||||||
|
- `backend/package.json`
|
||||||
|
- `backend/package-lock.json`
|
||||||
|
- `backend/src/db/client.ts`
|
||||||
|
- `backend/src/db/db-utils.ts`
|
||||||
|
- `backend/src/index.ts`
|
||||||
|
- `backend/src/routes/doses.ts`
|
||||||
|
- `backend/src/routes/health.ts`
|
||||||
|
- `backend/src/routes/settings.ts`
|
||||||
|
- `backend/src/test/e2e-routes.test.ts`
|
||||||
|
- `backend/src/utils/logger.ts`
|
||||||
|
- `frontend/nginx-entrypoint.sh`
|
||||||
|
- `frontend/nginx.conf`
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Ran `cd backend && npm run lint` → passed.
|
||||||
|
- Ran `cd frontend && npm run lint` → warning found in `src/components/MedicationAvatar.tsx` (`useExhaustiveDependencies`).
|
||||||
|
- Ran `cd backend && CI=true npm run test:run -- src/test/e2e-routes.test.ts` → passed (103/103).
|
||||||
|
- No code changes were made as part of this validation request.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Resolve frontend lint warning in `frontend/src/components/MedicationAvatar.tsx` before considering local pre-PR gate fully satisfied.
|
||||||
|
|
||||||
|
### 2026-02-26 — Structured Logging Implementation Plan
|
||||||
|
|
||||||
|
- **🧩 Scope**: Observability / logging improvements
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Created implementation plan to fix the log noise problem: nginx and Fastify log every 5-second dose-polling request at `info` level, making `info` unusable.
|
||||||
|
- Plan covers 5 phases: (1) suppress noisy backend routes via per-route `logLevel`, (2) add timestamps to startup logger + pino-pretty for dev, (3) suppress polling in nginx access logs, (4) differentiate debug/info/warn in nginx entrypoint, (5) update docs.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `plan/feature-structured-logging-1.md` (new)
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Implement the 18 tasks across 5 phases.
|
||||||
|
|
||||||
|
### 2026-02-26 — Structured Logging Implementation (complete)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Observability / logging — make `LOG_LEVEL=info` usable
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- **Backend route noise suppression**: `GET /health` (logLevel: warn), `GET /doses/taken` and `GET /share/:token/doses` (logLevel: debug) — these high-frequency polling routes no longer flood `info` logs with Pino's automatic `incoming request` / `request completed` messages.
|
||||||
|
- **Startup logger timestamps**: All pre-Fastify log messages (DB migrations, etc.) now include `[2026-02-26T14:30:05.123Z] [INFO]` prefix.
|
||||||
|
- **pino-pretty for development**: Backend dev mode now outputs human-readable, colorized log lines with translated timestamps (production still uses structured JSON).
|
||||||
|
- **nginx polling suppression**: New dedicated `location` blocks in `nginx.conf` for `/api/doses/taken`, `/api/share/*/doses`, and `/api/health` with conditional `access_log` via `NGINX_POLLING_LOG` variable.
|
||||||
|
- **nginx 3-tier LOG_LEVEL**: `debug` = all access logs, `info` = all except polling (default), `warn+` = no access logs.
|
||||||
|
- **nginx timestamps**: Custom `log_format timed` with ISO 8601 timestamps applied to all access logging.
|
||||||
|
- **Documentation**: `.env.example` and `README.md` updated with detailed per-level behavior.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `backend/src/routes/health.ts`
|
||||||
|
- `backend/src/routes/doses.ts`
|
||||||
|
- `backend/src/utils/logger.ts`
|
||||||
|
- `backend/src/index.ts`
|
||||||
|
- `backend/package.json` + `package-lock.json`
|
||||||
|
- `frontend/nginx.conf`
|
||||||
|
- `frontend/nginx-entrypoint.sh`
|
||||||
|
- `.env.example`
|
||||||
|
- `README.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Docker build + manual live verification
|
||||||
|
- Delegate automated test coverage to @testing-manager
|
||||||
|
|
||||||
|
### 2026-02-26
|
||||||
|
|
||||||
|
- **Scope**: Update governance instructions for persistent agent memory and user-readable reporting.
|
||||||
|
- **What changed**:
|
||||||
|
- Added a **VERY IMPORTANT** section to `.github/copilot-instructions.md`.
|
||||||
|
- Added a **VERY IMPORTANT — Memory + Reporting Persistence** section to `AGENTS.md`.
|
||||||
|
- Removed the obsolete mandatory `doku/APP_BEHAVIOR.md` persistence rule from `AGENTS.md`.
|
||||||
|
- Created `doku/memory_notes.md` and `doku/report.md`.
|
||||||
|
- **Files touched**:
|
||||||
|
- `.github/copilot-instructions.md`
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **Follow-ups**:
|
||||||
|
- Keep both files updated on every meaningful task going forward.
|
||||||
|
|
||||||
|
### 2026-02-26 (follow-up)
|
||||||
|
|
||||||
|
- **Scope**: Add ultra-short maintenance templates so future updates stay consistent.
|
||||||
|
- **What changed**:
|
||||||
|
- Added a "How to maintain (1-minute template)" section in this file.
|
||||||
|
- Added a matching "How to maintain" section in `doku/memory_notes.md`.
|
||||||
|
- **Files touched**:
|
||||||
|
- `doku/report.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- **Follow-ups**:
|
||||||
|
- Reuse the templates for all upcoming meaningful tasks.
|
||||||
|
|
||||||
|
### 2026-02-26 (emoji template follow-up)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Add emoji label conventions for faster, more readable scan in future entries.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Updated the report template labels to emoji-based headings.
|
||||||
|
- Updated the memory notes template labels to the same style.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/report.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Use this emoji format for all upcoming entries unless governance changes.
|
||||||
|
|
||||||
|
### 2026-02-26 (testing-manager instruction update)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Tighten testing governance in the `testing-manager` agent instructions.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Added mandatory linting gate: all lint errors and simple/fixable warnings must be resolved, especially before PR handoff from `@release-manager`.
|
||||||
|
- Added strict reliability/validity rules to avoid fake-green tests and over-mocking.
|
||||||
|
- Added a concrete test validity checklist focused on true functional verification.
|
||||||
|
- Updated command examples to current setup:
|
||||||
|
- Backend Vitest via `CI=true npm run test:run` / `test:coverage`
|
||||||
|
- Frontend Vitest via `CI=true npm run test:run` / `test:coverage`
|
||||||
|
- Playwright E2E with `PLAYWRIGHT_HTML_OPEN=never` and CI-stable worker guidance.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `.github/agents/testing-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Reuse these strengthened rules for future CI triage and pre-PR test handoffs.
|
||||||
|
|
||||||
|
### 2026-02-26 (pre-PR local gate update)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Make pre-PR quality requirements explicit for testing handoff.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Added explicit pre-PR rule: no PR creation before local lint is clean and relevant tests pass locally.
|
||||||
|
- Added explicit anti-pattern rule: do not let obvious regressions be discovered first in GitHub CI.
|
||||||
|
- Updated workflow/lint sections and done criteria to include this mandatory local gate.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `.github/agents/testing-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Enforce this gate in every future testing handoff before PR creation.
|
||||||
|
|
||||||
|
### 2026-02-26 (release-manager gate alignment)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Apply the same local quality gate requirements to `release-manager` workflow.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Added explicit pre-PR local gate rule in `release-manager`: lint clean + relevant tests passed locally before PR creation.
|
||||||
|
- Added explicit no CI-first-failure rule in `release-manager` critical safety section.
|
||||||
|
- Updated release workflow steps so push/PR creation is blocked until local gate is confirmed.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `.github/agents/release-manager.agent.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Reuse this policy consistently for all future release PR orchestration.
|
||||||
|
|
||||||
|
### 2026-02-26 (React 19 plan refinement)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Validate that the React 19 plan follows official best practices.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Confirmed from the React 19 upgrade guide: TypeScript projects should upgrade to `@types/react@^19` and `@types/react-dom@^19`.
|
||||||
|
- Updated recommendation: do not remove `@types/*` packages during this upgrade.
|
||||||
|
- Updated scope policy: keep upgrade PR focused on version bump and required compatibility fixes only.
|
||||||
|
- Marked optional feature adoption (`useOptimistic`, `useFormStatus`, Server Components, broader API migrations) as follow-up PR scope.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Apply this exact scope and dependency policy when implementing the React 19 upgrade branch.
|
||||||
|
|
||||||
|
### 2026-02-26 (React 19 implementation)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Execute the scoped React 19 dependency upgrade in frontend only.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Upgraded `react` and `react-dom` to `^19.2.0` in frontend dependencies.
|
||||||
|
- Upgraded `@types/react` and `@types/react-dom` to `^19.2.2` (kept them, not removed).
|
||||||
|
- Updated `frontend/package-lock.json` entries for `react`, `react-dom`, `scheduler`, `@types/react`, and `@types/react-dom` to matching 19.x metadata.
|
||||||
|
- Kept migration scope strict: no optional React 19 feature adoption or broad refactors.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/package.json`
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Delegate local validation (lint/check/tests) to `@testing-manager` before PR handoff.
|
||||||
|
|
||||||
|
### 2026-02-26 (Testing handoff execution)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Run `frontend` lint/check/relevant tests after React 19 upgrade and apply only mandatory compatibility fixes.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Ran `npm run lint` in `frontend`: 1 existing warning remains in `src/components/MedicationAvatar.tsx` (`useExhaustiveDependencies`).
|
||||||
|
- Ran `npm run check` in `frontend`: fixed compatibility/type errors in targeted tests:
|
||||||
|
- `src/test/utils/ics.test.ts` (typed mock assignments + fixture default safety)
|
||||||
|
- `src/test/utils/schedule.test.ts` (added required `packageType` in medication fixtures, event `id` field)
|
||||||
|
- `src/test/components/MobileEditModal.test.tsx` (added required `imageUploadError` prop and form-event typing)
|
||||||
|
- Ran focused test scope:
|
||||||
|
- `CI=true npm run test:run -- src/test/utils/ics.test.ts src/test/utils/schedule.test.ts src/test/components/MobileEditModal.test.tsx`
|
||||||
|
- Result: 3 files passed, 147 tests passed.
|
||||||
|
- `frontend check` is still blocked by unrelated type mismatches in `src/test/components/MedDetailModal.test.tsx` (new required props and `RefillEntry` shape drift).
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/src/test/utils/ics.test.ts`
|
||||||
|
- `frontend/src/test/utils/schedule.test.ts`
|
||||||
|
- `frontend/src/test/components/MobileEditModal.test.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Separate follow-up to align `MedDetailModal` tests with current `MedDetailModalProps` and `RefillEntry` type.
|
||||||
|
- Decide whether to resolve or waive the existing lint warning in `MedicationAvatar.tsx` for strict pre-PR gate.
|
||||||
|
|
||||||
|
### 2026-02-26 (Blocker follow-up)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Resolve remaining non-test lint blocker and prepare delegated test-fix handoff.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Fixed the remaining lint warning in `frontend/src/components/MedicationAvatar.tsx` by making image reset logic dependency-safe with previous-value tracking (`useRef`).
|
||||||
|
- Kept `MedDetailModal.test.tsx` adaptations delegated to `@testing-manager` per testing ownership rule.
|
||||||
|
- Prepared concrete handoff targets for `@testing-manager`:
|
||||||
|
- Add required props in test `defaultProps`: `usePrescriptionRefill`, `onUsePrescriptionRefillChange`.
|
||||||
|
- Update `RefillEntry` fixtures from old fields (`medicationId`, `timestamp`, `looseAdded`) to current shape (`refillDate`, `loosePillsAdded`).
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/src/components/MedicationAvatar.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- `@testing-manager` to run and fix the full `frontend check` residual failures in `src/test/components/MedDetailModal.test.tsx`.
|
||||||
|
|
||||||
|
### 2026-02-26 (Dependency update automation)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Automate dependency updates with controlled auto-merge.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Extended existing `.github/dependabot.yml` for weekly updates across `frontend`, `backend`, root npm tooling, and GitHub Actions.
|
||||||
|
- Added grouping for minor/patch updates in root npm and GitHub Actions to reduce PR noise.
|
||||||
|
- Added scoped labels (`frontend`, `backend`, `root`, `ci`) for easier triage.
|
||||||
|
- Added `.github/workflows/dependabot-automerge.yml` to enable auto-merge only for Dependabot patch/minor updates (npm + GitHub Actions), while major updates remain manual.
|
||||||
|
- Updated `README.md` with a new "Dependency Updates" section and changed the React badge to 19.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `.github/dependabot.yml`
|
||||||
|
- `.github/workflows/dependabot-automerge.yml`
|
||||||
|
- `README.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Verify repository branch protection required checks are configured so auto-merge waits for CI gates as intended.
|
||||||
|
|
||||||
|
### 2026-02-26 (Automatic handoff to testing-manager)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Execute delegated testing ownership without waiting for user confirmation.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Issued direct handoff to `@testing-manager` for residual `frontend check` blockers in `frontend/src/test/components/MedDetailModal.test.tsx`.
|
||||||
|
- Handoff checklist includes:
|
||||||
|
- add required `MedDetailModalProps` test props (`usePrescriptionRefill`, `onUsePrescriptionRefillChange`),
|
||||||
|
- align `RefillEntry` test fixtures to current type shape (`refillDate`, `loosePillsAdded`),
|
||||||
|
- run `cd frontend && npm run check` and report remaining deltas.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/report.md`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- After `@testing-manager` completion, continue with PR-ready summary and release handoff.
|
||||||
|
|
||||||
|
### 2026-02-26 (Continued execution: frontend check fully green)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Continue implementation to remove all remaining `frontend` type/lint blockers.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Fixed remaining production type/lint blockers in:
|
||||||
|
- `src/components/MobileEditModal.tsx` (prop destructuring + packageType change handler typing)
|
||||||
|
- `src/components/SharedSchedule.tsx` (critical threshold typing)
|
||||||
|
- `src/context/AppContext.tsx` (import result typing for imported counts)
|
||||||
|
- `src/pages/dashboard-helpers.ts` (strict `PackageType` + null-safe stockAdjustment)
|
||||||
|
- `src/pages/DashboardPage.tsx` (missing `Coverage` type import)
|
||||||
|
- `src/utils/stock.ts` (removed unreachable nullish coalescing)
|
||||||
|
- Fixed remaining test typing drift in:
|
||||||
|
- `src/test/setup.ts`
|
||||||
|
- `src/test/components/Lightbox.test.tsx`
|
||||||
|
- `src/test/components/UserFilterModal.test.tsx`
|
||||||
|
- `src/test/context/AppContext.test.tsx`
|
||||||
|
- `src/test/hooks/useMedications.test.ts`
|
||||||
|
- `src/test/hooks/useRefill.test.ts`
|
||||||
|
- `src/test/hooks/useSettings.test.ts`
|
||||||
|
- `src/test/hooks/useShare.test.ts`
|
||||||
|
- `src/test/utils/formatters.test.ts`
|
||||||
|
- `src/test/utils/schedule.test.ts`
|
||||||
|
- Validation results:
|
||||||
|
- `cd frontend && npm run check` -> **PASS**
|
||||||
|
- `CI=true npm run test:run -- src/test/hooks/useShare.test.ts src/test/hooks/useRefill.test.ts src/test/hooks/useSettings.test.ts src/test/utils/formatters.test.ts` -> **PASS** (4 files, 84 tests)
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/src/components/MobileEditModal.tsx`
|
||||||
|
- `frontend/src/components/SharedSchedule.tsx`
|
||||||
|
- `frontend/src/context/AppContext.tsx`
|
||||||
|
- `frontend/src/pages/dashboard-helpers.ts`
|
||||||
|
- `frontend/src/pages/DashboardPage.tsx`
|
||||||
|
- `frontend/src/utils/stock.ts`
|
||||||
|
- `frontend/src/test/setup.ts`
|
||||||
|
- `frontend/src/test/components/Lightbox.test.tsx`
|
||||||
|
- `frontend/src/test/components/UserFilterModal.test.tsx`
|
||||||
|
- `frontend/src/test/context/AppContext.test.tsx`
|
||||||
|
- `frontend/src/test/hooks/useMedications.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useRefill.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useSettings.test.ts`
|
||||||
|
- `frontend/src/test/hooks/useShare.test.ts`
|
||||||
|
- `frontend/src/test/utils/formatters.test.ts`
|
||||||
|
- `frontend/src/test/utils/schedule.test.ts`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Optional: run full frontend test suite as additional confidence step before release handoff.
|
||||||
|
|
||||||
|
### 2026-02-26 (npm integrity issue resolved)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Fix `npm ci` failure caused by tarball integrity mismatch warnings/errors.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Reproduced failure (`EINTEGRITY`) for `@types/react@19.2.2` / `@types/react-dom@19.2.2`.
|
||||||
|
- Pulled authoritative integrity hashes from npm registry via:
|
||||||
|
- `npm view @types/react@19.2.2 dist.integrity`
|
||||||
|
- `npm view @types/react-dom@19.2.2 dist.integrity`
|
||||||
|
- Corrected two integrity strings in `frontend/package-lock.json` to match official registry values.
|
||||||
|
- Re-ran install:
|
||||||
|
- `npm ci --no-audit --no-fund` -> **PASS**.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/package-lock.json`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- None required for this issue; install path is healthy again.
|
||||||
|
|
||||||
|
### 2026-02-26 (Deprecation warnings triage)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Investigate reported npm deprecation warnings and determine if local code changes are required.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Verified warnings are from `backend` transitive deps, not `frontend`:
|
||||||
|
- `drizzle-kit@0.31.9` -> `@esbuild-kit/esm-loader@2.6.5` -> `@esbuild-kit/core-utils@3.3.2`
|
||||||
|
- `@libsql/client@0.17.0` -> `node-fetch@3.3.2` -> `fetch-blob@3.2.0` -> `node-domexception@1.0.0`
|
||||||
|
- Confirmed current installed versions are already latest published for both direct parents (`drizzle-kit`, `@libsql/client`).
|
||||||
|
- Classified as non-blocking upstream deprecation warnings (no immediate local fix available without changing stack/library choices).
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Re-evaluate after upstream releases; remove warnings via normal dependency updates when available.
|
||||||
|
|
||||||
|
### 2026-02-26 (MedDetailModal test type drift fix)
|
||||||
|
|
||||||
|
- **🧩 Scope**: Fix only residual prop/type drift in `MedDetailModal` tests to unblock frontend check target area.
|
||||||
|
- **🛠️ What changed**:
|
||||||
|
- Updated `defaultProps` in `frontend/src/test/components/MedDetailModal.test.tsx` with required `MedDetailModalProps` fields:
|
||||||
|
- `usePrescriptionRefill`
|
||||||
|
- `onUsePrescriptionRefillChange`
|
||||||
|
- Updated `RefillEntry` fixtures in the same file to current type shape:
|
||||||
|
- removed legacy fields (`medicationId`, `timestamp`, `looseAdded`)
|
||||||
|
- added current fields (`refillDate`, `loosePillsAdded`)
|
||||||
|
- Ran `cd frontend && npm run check`: the file-specific drift is resolved, but command still fails due unrelated TypeScript errors in other frontend files.
|
||||||
|
- **📁 Files touched**:
|
||||||
|
- `frontend/src/test/components/MedDetailModal.test.tsx`
|
||||||
|
- `doku/memory_notes.md`
|
||||||
|
- `doku/report.md`
|
||||||
|
- **🔜 Follow-ups**:
|
||||||
|
- Resolve remaining unrelated `frontend` TypeScript errors before rerunning full `npm run check` and then the targeted MedDetailModal test command.
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
authFile,
|
||||||
|
createMedicationViaAPI,
|
||||||
|
deleteAllMedicationsViaAPI,
|
||||||
|
expect,
|
||||||
|
navigateTo,
|
||||||
|
type TestMedication,
|
||||||
|
test,
|
||||||
|
} from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tooltip Visibility Regression Tests
|
||||||
|
*
|
||||||
|
* Ensures that tooltip pseudo-elements on MedDetail footer icon buttons
|
||||||
|
* are not clipped by ancestor overflow or hidden behind modal overlays.
|
||||||
|
* This is a regression guard — tooltips have repeatedly broken due to
|
||||||
|
* CSS overflow/z-index changes on modal containers.
|
||||||
|
*/
|
||||||
|
test.describe("MedDetail footer tooltip visibility", () => {
|
||||||
|
test.use({ storageState: authFile });
|
||||||
|
test.describe.configure({ timeout: 60000 });
|
||||||
|
|
||||||
|
const MED_NAME = "Tooltip Test Med";
|
||||||
|
const createdMeds: TestMedication[] = [];
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
createdMeds.push(
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: MED_NAME,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: new Date().toISOString().slice(0, 16),
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the MedDetail modal by clicking a medication row in the Dashboard overview table.
|
||||||
|
*/
|
||||||
|
async function openMedDetailModal(page: import("@playwright/test").Page) {
|
||||||
|
await navigateTo(page, "/dashboard");
|
||||||
|
const overviewTable = page.locator(".table.table-7");
|
||||||
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: MED_NAME }).first();
|
||||||
|
await medRow.click();
|
||||||
|
|
||||||
|
const modal = page.locator(".modal-overlay.med-detail-overlay");
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no ancestor of footer tooltip buttons has overflow:hidden", async ({ page }) => {
|
||||||
|
const modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
const footer = modal.locator(".med-detail-footer");
|
||||||
|
await expect(footer).toBeVisible();
|
||||||
|
|
||||||
|
// Walk up from footer through modal-content to modal-overlay and check overflow
|
||||||
|
const overflowHiddenAncestors = await page.evaluate(() => {
|
||||||
|
const footer = document.querySelector(".med-detail-footer");
|
||||||
|
if (!footer) return ["footer not found"];
|
||||||
|
|
||||||
|
const problems: string[] = [];
|
||||||
|
let el: HTMLElement | null = footer as HTMLElement;
|
||||||
|
while (el && !el.classList.contains("modal-overlay")) {
|
||||||
|
const computed = window.getComputedStyle(el);
|
||||||
|
const overflowX = computed.overflowX;
|
||||||
|
const overflowY = computed.overflowY;
|
||||||
|
if (overflowX === "hidden" || overflowY === "hidden") {
|
||||||
|
const id = el.id ? `#${el.id}` : "";
|
||||||
|
const cls = el.className ? `.${el.className.split(" ").join(".")}` : "";
|
||||||
|
problems.push(`${el.tagName.toLowerCase()}${id}${cls} has overflow: ${overflowX}/${overflowY}`);
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
overflowHiddenAncestors,
|
||||||
|
`Tooltip ancestors must not clip with overflow:hidden: ${overflowHiddenAncestors.join("; ")}`
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tooltip z-index is above modal overlay", async ({ page }) => {
|
||||||
|
const _modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
// Get modal overlay z-index and tooltip pseudo-element z-index from CSS
|
||||||
|
const { modalZIndex, tooltipZIndex, arrowZIndex } = await page.evaluate(() => {
|
||||||
|
const overlay = document.querySelector(".modal-overlay");
|
||||||
|
const overlayZ = overlay ? Number.parseInt(window.getComputedStyle(overlay).zIndex, 10) : 0;
|
||||||
|
|
||||||
|
// Read the tooltip ::after z-index from stylesheets
|
||||||
|
let ttZ = 0;
|
||||||
|
let arrZ = 0;
|
||||||
|
for (const sheet of document.styleSheets) {
|
||||||
|
try {
|
||||||
|
for (const rule of sheet.cssRules) {
|
||||||
|
const cssRule = rule as CSSStyleRule;
|
||||||
|
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::after")) {
|
||||||
|
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||||
|
if (z > ttZ) ttZ = z;
|
||||||
|
}
|
||||||
|
if (cssRule.selectorText?.includes("tooltip-trigger[data-tooltip]::before")) {
|
||||||
|
const z = Number.parseInt(cssRule.style.zIndex, 10);
|
||||||
|
if (z > arrZ) arrZ = z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cross-origin sheets — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { modalZIndex: overlayZ, tooltipZIndex: ttZ, arrowZIndex: arrZ };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tooltipZIndex,
|
||||||
|
`Tooltip ::after z-index (${tooltipZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||||
|
).toBeGreaterThan(modalZIndex);
|
||||||
|
expect(
|
||||||
|
arrowZIndex,
|
||||||
|
`Tooltip ::before z-index (${arrowZIndex}) must be > modal overlay z-index (${modalZIndex})`
|
||||||
|
).toBeGreaterThan(modalZIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit button tooltip is visible on hover", async ({ page }) => {
|
||||||
|
const modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
const editBtn = modal.locator(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||||
|
await expect(editBtn).toBeVisible();
|
||||||
|
|
||||||
|
// Hover to activate tooltip
|
||||||
|
await editBtn.hover();
|
||||||
|
// Small wait for CSS transition
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Verify the tooltip pseudo-element is visible and within viewport
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.info.icon-only");
|
||||||
|
if (!btn) return { visible: false, reason: "button not found" };
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(btn, "::after");
|
||||||
|
const opacity = Number.parseFloat(style.opacity);
|
||||||
|
const visibility = style.visibility;
|
||||||
|
|
||||||
|
if (opacity < 0.5 || visibility === "hidden") {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { visible: true, reason: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible.visible, `Edit tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stock correction button tooltip is visible on hover", async ({ page }) => {
|
||||||
|
const modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
const stockBtn = modal.locator(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||||
|
await expect(stockBtn).toBeVisible();
|
||||||
|
|
||||||
|
await stockBtn.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.icon-stock-correction");
|
||||||
|
if (!btn) return { visible: false, reason: "button not found" };
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(btn, "::after");
|
||||||
|
const opacity = Number.parseFloat(style.opacity);
|
||||||
|
const visibility = style.visibility;
|
||||||
|
|
||||||
|
if (opacity < 0.5 || visibility === "hidden") {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { visible: true, reason: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible.visible, `Stock correction tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export button tooltip is visible on hover", async ({ page }) => {
|
||||||
|
const modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
const exportBtn = modal.locator(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||||
|
// Export button only shows when blisters exist — skip if not present
|
||||||
|
if (!(await exportBtn.isVisible().catch(() => false))) {
|
||||||
|
test.skip(true, "Export button not visible (no blisters)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await exportBtn.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const btn = document.querySelector(".med-detail-footer button.tooltip-trigger.secondary.icon-only");
|
||||||
|
if (!btn) return { visible: false, reason: "button not found" };
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(btn, "::after");
|
||||||
|
const opacity = Number.parseFloat(style.opacity);
|
||||||
|
const visibility = style.visibility;
|
||||||
|
|
||||||
|
if (opacity < 0.5 || visibility === "hidden") {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { visible: true, reason: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible.visible, `Export tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("close button tooltip in header is visible on hover", async ({ page }) => {
|
||||||
|
const modal = await openMedDetailModal(page);
|
||||||
|
|
||||||
|
const closeBtn = modal.locator("button.modal-close.tooltip-trigger");
|
||||||
|
await expect(closeBtn).toBeVisible();
|
||||||
|
|
||||||
|
await closeBtn.hover();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const isVisible = await page.evaluate(() => {
|
||||||
|
const btn = document.querySelector(".med-detail-overlay button.modal-close.tooltip-trigger");
|
||||||
|
if (!btn) return { visible: false, reason: "button not found" };
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(btn, "::after");
|
||||||
|
const opacity = Number.parseFloat(style.opacity);
|
||||||
|
const visibility = style.visibility;
|
||||||
|
|
||||||
|
if (opacity < 0.5 || visibility === "hidden") {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
reason: `opacity=${opacity}, visibility=${visibility}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { visible: true, reason: "ok" };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isVisible.visible, `Close button tooltip should be visible on hover: ${isVisible.reason}`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-2
@@ -6,7 +6,6 @@
|
|||||||
<title>MedAssist-ng</title>
|
<title>MedAssist-ng</title>
|
||||||
|
|
||||||
<!-- Favicons -->
|
<!-- Favicons -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
@@ -14,7 +13,7 @@
|
|||||||
|
|
||||||
<!-- Theme color -->
|
<!-- Theme color -->
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Custom log format with ISO timestamps
|
||||||
|
log_format timed '$time_iso8601 $status $request_method $request_uri ($request_time s)';
|
||||||
|
|
||||||
# Access log control (suppressed when LOG_LEVEL is warn or higher)
|
# Access log control (suppressed when LOG_LEVEL is warn or higher)
|
||||||
access_log ${NGINX_ACCESS_LOG};
|
access_log ${NGINX_ACCESS_LOG};
|
||||||
|
|
||||||
@@ -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
+232
-177
@@ -1,30 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.16.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.12.0",
|
"version": "1.16.1",
|
||||||
"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.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
"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.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
"@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.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
|
||||||
"version": "18.3.27",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"undici-types": "~7.18.0"
|
||||||
"csstype": "^3.2.2"
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.2.2",
|
||||||
"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.2.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"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,9 +2959,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
@@ -2926,16 +2971,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
@@ -2983,9 +3028,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 +3050,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 +3090,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 +3106,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 +3148,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 +3350,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.13.0",
|
"version": "1.17.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.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.2.0",
|
||||||
"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.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
"@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,8 @@ 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);
|
||||||
|
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
|
||||||
|
|
||||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
{
|
{
|
||||||
@@ -64,7 +66,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 |
+150
-80
@@ -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
|
||||||
@@ -113,14 +114,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 +190,24 @@ function AppContent() {
|
|||||||
// Local-only state (not shared across components)
|
// Local-only state (not shared across components)
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [routeTransitionMaskActive, setRouteTransitionMaskActive] = useState(false);
|
||||||
|
const routeTransitionMinEndRef = useRef(0);
|
||||||
|
const routeTransitionFallbackTimerRef = useRef<number | null>(null);
|
||||||
|
const closeProfile = useCallback(() => {
|
||||||
|
if (showProfile) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showProfile]);
|
||||||
|
|
||||||
|
const closeAbout = useCallback(() => {
|
||||||
|
if (showAbout) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showAbout]);
|
||||||
|
|
||||||
// Get centralized stockThresholds from context
|
// Get centralized stockThresholds from context
|
||||||
const { stockThresholds } = ctx;
|
const { stockThresholds } = ctx;
|
||||||
|
|
||||||
// Close modal on Escape key
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
// Close modals in order of priority (topmost first)
|
|
||||||
if (scheduleLightboxImage) {
|
|
||||||
closeScheduleLightbox();
|
|
||||||
} else if (showImageLightbox) {
|
|
||||||
closeImageLightbox();
|
|
||||||
} else if (showEditStockModal) {
|
|
||||||
closeEditStockModal();
|
|
||||||
} else if (showRefillModal) {
|
|
||||||
closeRefillModal();
|
|
||||||
} else if (showShareDialog) {
|
|
||||||
closeShareDialog();
|
|
||||||
} else if (showAbout) {
|
|
||||||
closeAbout();
|
|
||||||
} else if (showProfile) {
|
|
||||||
closeProfile();
|
|
||||||
} else if (selectedUser) {
|
|
||||||
closeUserFilter();
|
|
||||||
} else if (selectedMed) {
|
|
||||||
closeMedDetail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
|
||||||
}, [
|
|
||||||
selectedMed,
|
|
||||||
showImageLightbox,
|
|
||||||
scheduleLightboxImage,
|
|
||||||
selectedUser,
|
|
||||||
showProfile,
|
|
||||||
showAbout,
|
|
||||||
showShareDialog,
|
|
||||||
showRefillModal,
|
|
||||||
showEditStockModal,
|
|
||||||
closeAbout,
|
|
||||||
closeEditStockModal,
|
|
||||||
closeImageLightbox,
|
|
||||||
closeMedDetail,
|
|
||||||
closeProfile,
|
|
||||||
closeRefillModal,
|
|
||||||
closeScheduleLightbox,
|
|
||||||
closeShareDialog,
|
|
||||||
closeUserFilter,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle browser back button to close modals (in priority order)
|
// Handle browser back button to close modals (in priority order)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -331,21 +296,86 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Prevent background scroll when modal is open
|
// Global Escape handling in priority order.
|
||||||
|
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog;
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (isModalOpen) {
|
if (e.key !== "Escape") return;
|
||||||
document.documentElement.classList.add("modal-open");
|
|
||||||
document.body.classList.add("modal-open");
|
if (scheduleLightboxImage) {
|
||||||
} else {
|
closeScheduleLightbox();
|
||||||
document.documentElement.classList.remove("modal-open");
|
return;
|
||||||
document.body.classList.remove("modal-open");
|
}
|
||||||
}
|
if (showImageLightbox) {
|
||||||
return () => {
|
closeImageLightbox();
|
||||||
document.documentElement.classList.remove("modal-open");
|
return;
|
||||||
document.body.classList.remove("modal-open");
|
}
|
||||||
|
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 +404,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 +467,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 +577,8 @@ function AppContent() {
|
|||||||
{scheduleLightboxImage && (
|
{scheduleLightboxImage && (
|
||||||
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
<Lightbox src={scheduleLightboxImage} alt="Medication" onClose={closeScheduleLightbox} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className={`route-transition-mask${routeTransitionMaskActive ? " active" : ""}`} aria-hidden="true" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.warn("[Auth] Session refresh failed, clearing local user state", { correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
} else {
|
} else {
|
||||||
|
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.error("[Auth] Failed to refresh user", { error });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,31 +172,46 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Try to refresh the access token using the refresh token
|
// Try to refresh the access token using the refresh token
|
||||||
async function tryRefreshToken(): Promise<boolean> {
|
async function tryRefreshToken(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/refresh", {
|
const { correlationId, init } = withCorrelation(
|
||||||
method: "POST",
|
{
|
||||||
credentials: "include",
|
method: "POST",
|
||||||
});
|
credentials: "include",
|
||||||
|
},
|
||||||
|
"fe-auth-refresh"
|
||||||
|
);
|
||||||
|
const res = await fetch("/api/auth/refresh", init);
|
||||||
|
if (!res.ok) {
|
||||||
|
log.warn("[Auth] Token refresh rejected", { status: res.status, correlationId });
|
||||||
|
}
|
||||||
return res.ok;
|
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 +235,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", {
|
const { correlationId, init } = withCorrelation(
|
||||||
method: "POST",
|
{
|
||||||
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 +276,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
let code = "UNKNOWN";
|
||||||
throw new Error(err.error || "Upload failed");
|
try {
|
||||||
|
const body = (await res.json()) as { code?: string };
|
||||||
|
if (typeof body?.code === "string" && body.code.trim().length > 0) {
|
||||||
|
code = body.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No JSON body
|
||||||
|
}
|
||||||
|
throw new Error(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
@@ -377,7 +425,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 +433,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 +474,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 +540,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 +549,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 +622,32 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState("");
|
const [success, setSuccess] = useState("");
|
||||||
|
const [avatarError, setAvatarError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Close on Escape key
|
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||||
useEffect(() => {
|
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape" && onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
setAvatarError(t("form.imageUploadErrors.tooLarge"));
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAvatarLoading(true);
|
setAvatarLoading(true);
|
||||||
setError("");
|
setAvatarError("");
|
||||||
try {
|
try {
|
||||||
await uploadAvatar(file);
|
await uploadAvatar(file);
|
||||||
setSuccess(t("auth.avatarUpdated", "Avatar updated"));
|
setAvatarError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Upload failed");
|
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||||
|
setAvatarError(resolveImageUploadError(code, t));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
@@ -610,12 +656,13 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
async function handleAvatarDelete() {
|
async function handleAvatarDelete() {
|
||||||
setAvatarLoading(true);
|
setAvatarLoading(true);
|
||||||
setError("");
|
setAvatarError("");
|
||||||
try {
|
try {
|
||||||
await deleteAvatar();
|
await deleteAvatar();
|
||||||
setSuccess(t("auth.avatarRemoved", "Avatar removed"));
|
setAvatarError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Delete failed");
|
const code = err instanceof Error ? err.message : "UNKNOWN";
|
||||||
|
setAvatarError(resolveImageUploadError(code, t));
|
||||||
} finally {
|
} finally {
|
||||||
setAvatarLoading(false);
|
setAvatarLoading(false);
|
||||||
}
|
}
|
||||||
@@ -710,6 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="profile-username">{user.username}</span>
|
<span className="profile-username">{user.username}</span>
|
||||||
|
{avatarError && <span className="field-error">{avatarError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleUpdate} className="profile-form">
|
<form onSubmit={handleUpdate} className="profile-form">
|
||||||
|
|||||||
@@ -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 confirm-modal"
|
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}>
|
||||||
|
|||||||
@@ -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,13 +6,16 @@
|
|||||||
* 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 } 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";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
@@ -153,21 +156,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);
|
||||||
event.stopPropagation();
|
useEscapeKey(showRefillModal, onCloseRefillModal);
|
||||||
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;
|
||||||
@@ -200,7 +193,7 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
if (!selectedMed) return null;
|
if (!selectedMed) return 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 =
|
||||||
@@ -273,6 +266,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"
|
||||||
@@ -282,14 +283,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"
|
||||||
@@ -319,16 +312,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}
|
||||||
@@ -339,6 +323,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"
|
||||||
@@ -367,21 +360,15 @@ export function MedDetailModal({
|
|||||||
onCloseEditStockModal();
|
onCloseEditStockModal();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation();
|
if (e.key !== "Escape") 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") {
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onCloseEditStockModal();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -393,7 +380,7 @@ 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" && (
|
{selectedMed.packageType === "blister" && (
|
||||||
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
<p className="edit-stock-cap-info edit-stock-live-breakdown">
|
||||||
@@ -474,7 +461,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);
|
||||||
@@ -503,7 +490,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);
|
||||||
@@ -560,7 +547,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);
|
||||||
@@ -646,8 +633,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
|
||||||
@@ -655,14 +641,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"
|
||||||
@@ -686,18 +667,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
|
||||||
@@ -815,35 +798,49 @@ export function MedDetailModal({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-detail-schedules">
|
<div className="med-detail-schedules">
|
||||||
{selectedMed.blisters.map((blister, idx) => {
|
{(selectedMed.intakes && selectedMed.intakes.length > 0
|
||||||
// When using new intakes format with per-intake takenBy,
|
? selectedMed.intakes
|
||||||
// each intake already represents one person's dose — don't multiply.
|
: selectedMed.blisters.map((blister) => ({
|
||||||
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
usage: blister.usage,
|
||||||
const intake = selectedMed.intakes?.[idx];
|
every: blister.every,
|
||||||
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
start: blister.start,
|
||||||
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
takenBy: null,
|
||||||
const totalUsage = blister.usage * personCount;
|
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
|
||||||
|
}))
|
||||||
|
).map((intake, idx) => {
|
||||||
|
const hasPerIntakeTakenBy = !!intake.takenBy;
|
||||||
|
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
|
||||||
|
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
|
||||||
|
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="med-schedule-item">
|
<div key={`${intake.start}-${intake.usage}-${intake.every}-${idx}`} className="med-schedule-item">
|
||||||
<span className="med-schedule-usage">
|
<span className="med-schedule-usage">
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{selectedMed.pillWeightMg &&
|
{selectedMed.pillWeightMg &&
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="med-schedule-freq">
|
<span className="med-schedule-freq">
|
||||||
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||||
</span>
|
</span>
|
||||||
{hasPerIntakeTakenBy && intake.takenBy && (
|
{hasPerIntakeTakenBy && (
|
||||||
<span className="med-schedule-person">{intake.takenBy}</span>
|
<span className="med-schedule-person">
|
||||||
|
{intake.takenBy}
|
||||||
|
{showIntakeBell && (
|
||||||
|
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||||
|
<Bell size={13} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{intake?.intakeRemindersEnabled && (
|
{!hasPerIntakeTakenBy && showIntakeBell && (
|
||||||
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||||
<Bell size={13} aria-hidden="true" />
|
<Bell size={13} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="med-schedule-time">
|
<span className="med-schedule-time">
|
||||||
{t("modal.at")}{" "}
|
{t("modal.at")}{" "}
|
||||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
@@ -1022,7 +1019,11 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
{/* 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 */}
|
||||||
@@ -1034,14 +1035,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"
|
||||||
@@ -1053,7 +1055,7 @@ 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" ? (
|
{selectedMed.packageType === "blister" ? (
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
* Handles new medication creation and editing existing medications
|
* Handles new medication creation and editing existing medications
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
import { Bell, Minus, Plus, Trash2 } 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 { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||||
import { DOSE_UNITS } from "../types";
|
import { DOSE_UNITS } from "../types";
|
||||||
import { deriveTotal } from "../utils";
|
import { deriveTotal } from "../utils";
|
||||||
import { DateInput } from "./DateInput";
|
import { DateInput } from "./DateInput";
|
||||||
|
import { FormNumberStepper } from "./FormNumberStepper";
|
||||||
|
|
||||||
// Field limits for validation
|
// Field limits for validation
|
||||||
const FIELD_LIMITS = {
|
const FIELD_LIMITS = {
|
||||||
@@ -55,6 +59,7 @@ export interface MobileEditModalProps {
|
|||||||
meds: Medication[];
|
meds: Medication[];
|
||||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||||
onDeleteMedImage: (medId: number) => Promise<void>;
|
onDeleteMedImage: (medId: number) => Promise<void>;
|
||||||
|
imageUploadError: string | null;
|
||||||
// Actions
|
// Actions
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onResetForm: () => void;
|
onResetForm: () => void;
|
||||||
@@ -91,9 +96,9 @@ export function MobileEditModal({
|
|||||||
onAddTakenByPerson,
|
onAddTakenByPerson,
|
||||||
onRemoveTakenByPerson,
|
onRemoveTakenByPerson,
|
||||||
onTakenByKeyDown,
|
onTakenByKeyDown,
|
||||||
onSetBlisterValue,
|
onSetBlisterValue: _onSetBlisterValue,
|
||||||
onAddBlister,
|
onAddBlister: _onAddBlister,
|
||||||
onRemoveBlister,
|
onRemoveBlister: _onRemoveBlister,
|
||||||
onSetIntakeValue,
|
onSetIntakeValue,
|
||||||
onAddIntake,
|
onAddIntake,
|
||||||
onRemoveIntake,
|
onRemoveIntake,
|
||||||
@@ -101,11 +106,14 @@ export function MobileEditModal({
|
|||||||
meds,
|
meds,
|
||||||
onUploadMedImage,
|
onUploadMedImage,
|
||||||
onDeleteMedImage,
|
onDeleteMedImage,
|
||||||
|
imageUploadError,
|
||||||
onClose,
|
onClose,
|
||||||
_onResetForm,
|
onResetForm: _onResetForm,
|
||||||
onSaveMedication,
|
onSaveMedication,
|
||||||
}: MobileEditModalProps) {
|
}: MobileEditModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const decrementValueLabel = t("editStock.decreaseValue");
|
||||||
|
const incrementValueLabel = t("editStock.increaseValue");
|
||||||
const [activeTab, setActiveTab] = useState<MobileTab>("general");
|
const [activeTab, setActiveTab] = useState<MobileTab>("general");
|
||||||
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
||||||
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -114,74 +122,27 @@ export function MobileEditModal({
|
|||||||
const swipeAxisRef = useRef<"x" | "y" | null>(null);
|
const swipeAxisRef = useRef<"x" | "y" | null>(null);
|
||||||
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
|
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
|
||||||
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
|
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
|
||||||
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
const activeTabIndexRef = useRef(0);
|
const activeTabIndexRef = useRef(0);
|
||||||
|
|
||||||
// Reset tab when modal opens
|
// Reset tab when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (show) setActiveTab("general");
|
if (show) {
|
||||||
|
setActiveTab("general");
|
||||||
|
setShowNameValidation(false);
|
||||||
|
}
|
||||||
}, [show]);
|
}, [show]);
|
||||||
|
|
||||||
// Close on Escape key
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return;
|
if (show && (hasValidationErrors || !!fieldErrors.name)) {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
setShowNameValidation(true);
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
}, [show, hasValidationErrors, fieldErrors.name]);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [show, onClose]);
|
useEscapeKey(show, onClose);
|
||||||
|
|
||||||
// Lock background scroll while modal is open.
|
// Lock background scroll while modal is open.
|
||||||
useEffect(() => {
|
useScrollLock(show);
|
||||||
if (!show) return;
|
|
||||||
const html = document.documentElement;
|
|
||||||
const body = document.body;
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
|
|
||||||
const hadHtmlModalClass = html.classList.contains("modal-open");
|
|
||||||
const hadBodyModalClass = body.classList.contains("modal-open");
|
|
||||||
|
|
||||||
const previousHtmlOverflow = html.style.overflow;
|
|
||||||
const previousHtmlOverscrollBehavior = html.style.overscrollBehavior;
|
|
||||||
const previousBodyOverflow = body.style.overflow;
|
|
||||||
const previousBodyPosition = body.style.position;
|
|
||||||
const previousBodyTop = body.style.top;
|
|
||||||
const previousBodyLeft = body.style.left;
|
|
||||||
const previousBodyRight = body.style.right;
|
|
||||||
const previousBodyWidth = body.style.width;
|
|
||||||
const previousBodyOverscrollBehavior = body.style.overscrollBehavior;
|
|
||||||
|
|
||||||
html.classList.add("modal-open");
|
|
||||||
body.classList.add("modal-open");
|
|
||||||
html.style.overflow = "hidden";
|
|
||||||
html.style.overscrollBehavior = "none";
|
|
||||||
body.style.overflow = "hidden";
|
|
||||||
body.style.position = "fixed";
|
|
||||||
body.style.top = `-${scrollY}px`;
|
|
||||||
body.style.left = "0";
|
|
||||||
body.style.right = "0";
|
|
||||||
body.style.width = "100%";
|
|
||||||
body.style.overscrollBehavior = "none";
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!hadHtmlModalClass) html.classList.remove("modal-open");
|
|
||||||
if (!hadBodyModalClass) body.classList.remove("modal-open");
|
|
||||||
|
|
||||||
html.style.overflow = previousHtmlOverflow;
|
|
||||||
html.style.overscrollBehavior = previousHtmlOverscrollBehavior;
|
|
||||||
body.style.overflow = previousBodyOverflow;
|
|
||||||
body.style.position = previousBodyPosition;
|
|
||||||
body.style.top = previousBodyTop;
|
|
||||||
body.style.left = previousBodyLeft;
|
|
||||||
body.style.right = previousBodyRight;
|
|
||||||
body.style.width = previousBodyWidth;
|
|
||||||
body.style.overscrollBehavior = previousBodyOverscrollBehavior;
|
|
||||||
|
|
||||||
window.scrollTo(0, scrollY);
|
|
||||||
};
|
|
||||||
}, [show]);
|
|
||||||
|
|
||||||
// Keep activeTabIndex ref in sync for native listeners
|
// Keep activeTabIndex ref in sync for native listeners
|
||||||
const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab);
|
const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab);
|
||||||
@@ -292,7 +253,10 @@ export function MobileEditModal({
|
|||||||
const mobileTitle = (() => {
|
const mobileTitle = (() => {
|
||||||
if (!editingId) return t("form.newEntry");
|
if (!editingId) return t("form.newEntry");
|
||||||
if (readOnlyMode) return t("form.viewEntry");
|
if (readOnlyMode) return t("form.viewEntry");
|
||||||
const medicationName = currentMed?.name?.trim() || form.name.trim();
|
const medicationName =
|
||||||
|
(currentMed ? currentMed.name?.trim() || currentMed.genericName?.trim() : null) ||
|
||||||
|
form.name.trim() ||
|
||||||
|
form.genericName.trim();
|
||||||
if (!medicationName) return t("form.editEntry");
|
if (!medicationName) return t("form.editEntry");
|
||||||
return t("form.editEntryWithName", { name: medicationName });
|
return t("form.editEntryWithName", { name: medicationName });
|
||||||
})();
|
})();
|
||||||
@@ -302,13 +266,15 @@ export function MobileEditModal({
|
|||||||
className="modal-overlay mobile-edit-overlay"
|
className="modal-overlay mobile-edit-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 edit-modal"
|
className="modal-content edit-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Escape") e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="edit-modal-header">
|
<div className="edit-modal-header">
|
||||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||||
@@ -385,26 +351,41 @@ export function MobileEditModal({
|
|||||||
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
<label
|
||||||
|
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.name ? "has-error" : ""}`}
|
||||||
|
>
|
||||||
{t("form.commercialName")}
|
{t("form.commercialName")}
|
||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
onFormChange({ ...form, name: e.target.value });
|
||||||
|
}}
|
||||||
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.commercial")}
|
placeholder={t("form.placeholders.commercial")}
|
||||||
maxLength={FIELD_LIMITS.name.max}
|
maxLength={FIELD_LIMITS.name.max}
|
||||||
required={!readOnlyMode}
|
|
||||||
/>
|
/>
|
||||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
{!readOnlyMode && showNameValidation && fieldErrors.name && (
|
||||||
|
<span className="field-error">{fieldErrors.name}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
<label
|
||||||
|
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}
|
||||||
|
>
|
||||||
{t("form.genericName")}
|
{t("form.genericName")}
|
||||||
<input
|
<input
|
||||||
value={form.genericName}
|
value={form.genericName}
|
||||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
onFormChange({ ...form, genericName: e.target.value });
|
||||||
|
}}
|
||||||
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.generic")}
|
placeholder={t("form.placeholders.generic")}
|
||||||
maxLength={FIELD_LIMITS.genericName.max}
|
maxLength={FIELD_LIMITS.genericName.max}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
{!readOnlyMode && showNameValidation && fieldErrors.genericName && (
|
||||||
|
<span className="field-error">{fieldErrors.genericName}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.medicationStartDate")}
|
{t("form.medicationStartDate")}
|
||||||
@@ -421,7 +402,7 @@ export function MobileEditModal({
|
|||||||
<select
|
<select
|
||||||
className="package-type-select"
|
className="package-type-select"
|
||||||
value={form.packageType}
|
value={form.packageType}
|
||||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
|
||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
@@ -485,9 +466,14 @@ export function MobileEditModal({
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) void onUploadMedImage(editingId, file);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -498,32 +484,32 @@ export function MobileEditModal({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.packs")}
|
{t("form.packs")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.packCount}
|
value={form.packCount}
|
||||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blistersPerPack")}
|
{t("form.blistersPerPack")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.blistersPerPack}
|
value={form.blistersPerPack}
|
||||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.pillsPerBlister")}
|
{t("form.pillsPerBlister")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.pillsPerBlister}
|
value={form.pillsPerBlister}
|
||||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -535,22 +521,22 @@ export function MobileEditModal({
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.totalCapacity")}
|
{t("form.totalCapacity")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.totalPills}
|
value={form.totalPills}
|
||||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.currentPills")}
|
{t("form.currentPills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
value={form.looseTablets}
|
||||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
@@ -639,25 +625,30 @@ export function MobileEditModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{form.intakes.map((intake, idx) => (
|
{form.intakes.map((intake, idx) => (
|
||||||
<div key={idx} className="blister-row">
|
<div
|
||||||
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
||||||
|
className="blister-row"
|
||||||
|
>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.usage")}</span>
|
<span>{t("form.blisters.usage")}</span>
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||||
|
min={0.5}
|
||||||
|
step={0.5}
|
||||||
|
allowDecimal={true}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact">
|
<label className="compact">
|
||||||
<span>{t("form.blisters.everyDays")}</span>
|
<span>{t("form.blisters.everyDays")}</span>
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={intake.every}
|
value={intake.every}
|
||||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="compact full-row">
|
<label className="compact full-row">
|
||||||
@@ -736,32 +727,32 @@ export function MobileEditModal({
|
|||||||
<>
|
<>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.authorizedRefills")}
|
{t("prescription.authorizedRefills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionAuthorizedRefills}
|
value={form.prescriptionAuthorizedRefills}
|
||||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.remainingRefills")}
|
{t("prescription.remainingRefills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionRemainingRefills}
|
value={form.prescriptionRemainingRefills}
|
||||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.lowThreshold")}
|
{t("prescription.lowThreshold")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionLowRefillThreshold}
|
value={form.prescriptionLowRefillThreshold}
|
||||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
onChange={(nextValue) => onHandleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
|
|||||||
@@ -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,9 @@
|
|||||||
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 } from "../types";
|
||||||
import { MedicationAvatar } from "./MedicationAvatar";
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
|
||||||
type ReportFormat = "txt" | "md" | "pdf";
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
@@ -16,6 +18,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 +33,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 +143,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 +200,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 +218,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>
|
||||||
))}
|
))}
|
||||||
@@ -312,13 +320,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(
|
||||||
@@ -382,6 +392,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)));
|
||||||
@@ -478,22 +491,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 +531,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;
|
||||||
}
|
}
|
||||||
@@ -580,6 +595,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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } 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,11 @@ 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 }
|
||||||
) {
|
) {
|
||||||
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 (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" };
|
||||||
@@ -149,15 +152,7 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Close lightbox on Escape key
|
// Close lightbox on Escape key
|
||||||
useEffect(() => {
|
useEscapeKey(!!lightboxImage, closeLightbox);
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape" && lightboxImage) {
|
|
||||||
closeLightbox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [lightboxImage, closeLightbox]);
|
|
||||||
|
|
||||||
// Handle browser back button to close lightbox
|
// Handle browser back button to close lightbox
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -348,7 +343,7 @@ 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,
|
||||||
isPast,
|
isPast,
|
||||||
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
|
||||||
@@ -552,8 +547,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]);
|
||||||
@@ -751,7 +746,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 +800,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];
|
||||||
@@ -830,10 +825,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));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -989,7 +984,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];
|
||||||
@@ -1013,10 +1008,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));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1166,7 +1161,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;
|
||||||
@@ -1189,10 +1184,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));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1281,7 +1276,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 +1287,9 @@ export function SharedSchedule() {
|
|||||||
alt={lightboxImage.name}
|
alt={lightboxImage.name}
|
||||||
className="lightbox-image"
|
className="lightbox-image"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => 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,7 +64,7 @@ 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)
|
||||||
@@ -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,9 +1,9 @@
|
|||||||
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 } from "../utils/schedule";
|
||||||
@@ -72,6 +72,7 @@ export interface AppContextValue {
|
|||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -127,7 +128,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>;
|
||||||
@@ -252,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||||
|
const selectedMedIdRef = useRef<number | null>(null);
|
||||||
|
const medDetailOpenedAtRef = useRef(0);
|
||||||
|
const medDetailCloseInFlightRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
selectedMedIdRef.current = selectedMed?.id ?? null;
|
||||||
|
if (!selectedMed) {
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [selectedMed]);
|
||||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||||
|
const imageLightboxOpenedAtRef = useRef(0);
|
||||||
|
const imageLightboxCloseInFlightRef = useRef(false);
|
||||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||||
|
const scheduleLightboxOpenedAtRef = useRef(0);
|
||||||
|
const scheduleLightboxCloseInFlightRef = useRef(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showImageLightbox) {
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduleLightboxImage) {
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -466,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Modal helpers with browser history support
|
// Modal helpers with browser history support
|
||||||
const openMedDetail = useCallback(
|
const openMedDetail = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
|
if (selectedMedIdRef.current === med.id) return;
|
||||||
|
selectedMedIdRef.current = med.id;
|
||||||
|
medDetailOpenedAtRef.current = Date.now();
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
setSelectedMed(med);
|
setSelectedMed(med);
|
||||||
refill.setRefillHistoryExpanded(false);
|
refill.setRefillHistoryExpanded(false);
|
||||||
refill.loadRefillHistory(med.id);
|
refill.loadRefillHistory(med.id);
|
||||||
@@ -475,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closeMedDetail = useCallback(() => {
|
const closeMedDetail = useCallback(() => {
|
||||||
if (selectedMed) {
|
if (!selectedMed || medDetailCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
|
||||||
|
// Ignore ultra-fast close requests caused by rapid double-click races
|
||||||
|
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "medDetail") {
|
||||||
|
// State already popped by another event: close locally without another back step.
|
||||||
|
selectedMedIdRef.current = null;
|
||||||
|
setSelectedMed(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
medDetailCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
}, [selectedMed]);
|
}, [selectedMed]);
|
||||||
|
|
||||||
const openImageLightbox = useCallback(() => {
|
const openImageLightbox = useCallback(() => {
|
||||||
|
if (showImageLightbox) return;
|
||||||
|
imageLightboxOpenedAtRef.current = Date.now();
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
setShowImageLightbox(true);
|
setShowImageLightbox(true);
|
||||||
window.history.pushState({ modal: "imageLightbox" }, "");
|
window.history.pushState({ modal: "imageLightbox" }, "");
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeImageLightbox = useCallback(() => {
|
|
||||||
if (showImageLightbox) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}, [showImageLightbox]);
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
const closeImageLightbox = useCallback(() => {
|
||||||
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) {
|
||||||
@@ -596,7 +665,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 {
|
||||||
@@ -611,11 +691,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
|
||||||
@@ -742,6 +823,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,6 +4,7 @@ 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";
|
||||||
@@ -11,6 +12,7 @@ export { useMedications } from "./useMedications";
|
|||||||
export { useModalHistory } from "./useModalHistory";
|
export { useModalHistory } from "./useModalHistory";
|
||||||
export type { UseRefillReturn } from "./useRefill";
|
export type { UseRefillReturn } from "./useRefill";
|
||||||
export { useRefill } from "./useRefill";
|
export { useRefill } from "./useRefill";
|
||||||
|
export { useScrollLock } from "./useScrollLock";
|
||||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||||
export { useSettings } from "./useSettings";
|
export { useSettings } from "./useSettings";
|
||||||
export type { UseShareReturn } from "./useShare";
|
export type { UseShareReturn } from "./useShare";
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
|
|||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
takenDoseTimestamps: Map<string, number>;
|
takenDoseTimestamps: Map<string, number>;
|
||||||
|
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||||
dismissedDoses: Set<string>;
|
dismissedDoses: Set<string>;
|
||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
|
|||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||||
|
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
||||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const timestamps = new Map<string, number>();
|
const timestamps = new Map<string, number>();
|
||||||
|
const sources = new Map<string, "manual" | "automatic">();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses) {
|
for (const d of data.doses) {
|
||||||
if (d.dismissed) {
|
if (d.dismissed) {
|
||||||
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
timestamps.set(d.doseId, d.takenAt);
|
timestamps.set(d.doseId, d.takenAt);
|
||||||
|
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setTakenDoseTimestamps(timestamps);
|
setTakenDoseTimestamps(timestamps);
|
||||||
|
setTakenDoseSources(sources);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
|
|||||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isDoseTakenAutomatically = useCallback(
|
||||||
|
(doseId: string): boolean => {
|
||||||
|
return takenDoseSources.get(doseId) === "automatic";
|
||||||
|
},
|
||||||
|
[takenDoseSources]
|
||||||
|
);
|
||||||
|
|
||||||
// Count taken doses for a day/item
|
// Count taken doses for a day/item
|
||||||
const countTakenDoses = useCallback(
|
const countTakenDoses = useCallback(
|
||||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||||
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.set(doseId, Date.now());
|
next.set(doseId, Date.now());
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(doseId, "manual");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
mutationInFlightRef.current--;
|
mutationInFlightRef.current--;
|
||||||
// Re-sync with server after mutation completes
|
// Re-sync with server after mutation completes
|
||||||
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
takenDoses,
|
takenDoses,
|
||||||
setTakenDoses,
|
setTakenDoses,
|
||||||
takenDoseTimestamps,
|
takenDoseTimestamps,
|
||||||
|
takenDoseSources,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
countTakenDoses,
|
countTakenDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a modal/overlay when the user presses Escape.
|
||||||
|
*
|
||||||
|
* Registers a document-level `keydown` listener so it works regardless
|
||||||
|
* of which element has focus. Every modal **must** use this hook —
|
||||||
|
* relying on `onKeyDown` on overlay divs is unreliable because those
|
||||||
|
* handlers only fire when the overlay itself (or a descendant) has focus.
|
||||||
|
*
|
||||||
|
* @param active – whether the modal is currently open
|
||||||
|
* @param onClose – callback to close the modal
|
||||||
|
* @param options.capture – use capture phase (default: false).
|
||||||
|
* Set to `true` for nested sub-modals that must intercept Escape
|
||||||
|
* before a parent's handler fires.
|
||||||
|
*/
|
||||||
|
export function useEscapeKey(active: boolean, onClose: () => void, options?: { capture?: boolean }): void {
|
||||||
|
const capture = options?.capture ?? false;
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
||||||
|
// Keep refs in sync without re-registering the listener
|
||||||
|
activeRef.current = active;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && activeRef.current) {
|
||||||
|
onCloseRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown, capture);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown, capture);
|
||||||
|
}, [active, capture]);
|
||||||
|
}
|
||||||
@@ -115,9 +115,6 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
// Skip validation for takenBy array (individual items validated on add)
|
// Skip validation for takenBy array (individual items validated on add)
|
||||||
if (field === "takenBy") return undefined;
|
if (field === "takenBy") return undefined;
|
||||||
const strValue = typeof value === "string" ? value : "";
|
const strValue = typeof value === "string" ? value : "";
|
||||||
if (field === "name" && (!strValue || strValue.trim().length === 0)) {
|
|
||||||
return t("common.validation.required");
|
|
||||||
}
|
|
||||||
if ("max" in limits && strValue.length > limits.max) {
|
if ("max" in limits && strValue.length > limits.max) {
|
||||||
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
|
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
|
||||||
}
|
}
|
||||||
@@ -150,8 +147,16 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const error = validateField(f, form[f]);
|
const error = validateField(f, form[f]);
|
||||||
if (error) errors[f] = error;
|
if (error) errors[f] = error;
|
||||||
});
|
});
|
||||||
|
// Cross-field validation: at least one of name or genericName is required
|
||||||
|
const hasName = form.name && form.name.trim().length > 0;
|
||||||
|
const hasGenericName = form.genericName && form.genericName.trim().length > 0;
|
||||||
|
if (!hasName && !hasGenericName) {
|
||||||
|
const msg = t("common.validation.nameOrGenericRequired");
|
||||||
|
errors.name = errors.name || msg;
|
||||||
|
errors.genericName = errors.genericName || msg;
|
||||||
|
}
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
}, [form.name, form.genericName, form.notes, validateField]);
|
}, [form.name, form.genericName, form.notes, validateField, form, t]);
|
||||||
|
|
||||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
|
|||||||
@@ -50,13 +50,33 @@ export function useMedications(): UseMedicationsReturn {
|
|||||||
body: formData,
|
body: formData,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (!res.ok) {
|
||||||
loadMeds();
|
let code = "UNKNOWN";
|
||||||
|
try {
|
||||||
|
const errorBody = (await res.json()) as { code?: string };
|
||||||
|
if (typeof errorBody?.code === "string" && errorBody.code.trim().length > 0) {
|
||||||
|
code = errorBody.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep fallback code when backend response has no JSON body.
|
||||||
|
}
|
||||||
|
throw new Error(code);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore
|
loadMeds();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Network failures (fetch itself throws) produce browser-specific messages.
|
||||||
|
// Normalise to NETWORK_ERROR code so the UI can map to a translated string.
|
||||||
|
if (error.message === "Failed to fetch" || error.message.startsWith("NetworkError")) {
|
||||||
|
throw new Error("NETWORK_ERROR");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("UNKNOWN");
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
}
|
}
|
||||||
setUploadingImage(false);
|
|
||||||
},
|
},
|
||||||
[loadMeds]
|
[loadMeds]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock background scrolling when a modal/overlay is visible.
|
||||||
|
*
|
||||||
|
* Uses the `position: fixed` technique to prevent scroll on iOS Safari
|
||||||
|
* and other browsers where `overflow: hidden` alone is insufficient.
|
||||||
|
* Saves and restores the scroll position on cleanup so users don't
|
||||||
|
* lose their place.
|
||||||
|
*
|
||||||
|
* Supports nesting: a scroll-lock counter prevents premature unlock
|
||||||
|
* when multiple modals stack (e.g. MedDetail → RefillModal).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let lockCount = 0;
|
||||||
|
let savedScrollY = 0;
|
||||||
|
|
||||||
|
export function useScrollLock(active: boolean): void {
|
||||||
|
const wasActive = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && !wasActive.current) {
|
||||||
|
wasActive.current = true;
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (lockCount === 0) {
|
||||||
|
savedScrollY = window.scrollY;
|
||||||
|
html.classList.add("modal-open");
|
||||||
|
html.style.overflow = "hidden";
|
||||||
|
html.style.overscrollBehavior = "none";
|
||||||
|
body.classList.add("modal-open");
|
||||||
|
body.style.overflow = "hidden";
|
||||||
|
body.style.position = "fixed";
|
||||||
|
body.style.top = `-${savedScrollY}px`;
|
||||||
|
body.style.left = "0";
|
||||||
|
body.style.right = "0";
|
||||||
|
body.style.width = "100%";
|
||||||
|
body.style.overscrollBehavior = "none";
|
||||||
|
}
|
||||||
|
lockCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active && wasActive.current) {
|
||||||
|
wasActive.current = false;
|
||||||
|
lockCount--;
|
||||||
|
if (lockCount <= 0) {
|
||||||
|
lockCount = 0;
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (wasActive.current) {
|
||||||
|
wasActive.current = false;
|
||||||
|
lockCount--;
|
||||||
|
if (lockCount <= 0) {
|
||||||
|
lockCount = 0;
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlock(): void {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
html.classList.remove("modal-open");
|
||||||
|
html.style.overflow = "";
|
||||||
|
html.style.overscrollBehavior = "";
|
||||||
|
body.classList.remove("modal-open");
|
||||||
|
body.style.overflow = "";
|
||||||
|
body.style.position = "";
|
||||||
|
body.style.top = "";
|
||||||
|
body.style.left = "";
|
||||||
|
body.style.right = "";
|
||||||
|
body.style.width = "";
|
||||||
|
body.style.overscrollBehavior = "";
|
||||||
|
window.scrollTo(0, savedScrollY);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
|
import { withCorrelation } from "../utils/correlation";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
export interface UseShareReturn {
|
export interface UseShareReturn {
|
||||||
showShareDialog: boolean;
|
showShareDialog: boolean;
|
||||||
@@ -45,36 +47,57 @@ export function useShare(): UseShareReturn {
|
|||||||
const allPeople = meds.flatMap((m) => m.takenBy || []);
|
const allPeople = meds.flatMap((m) => m.takenBy || []);
|
||||||
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
||||||
setSharePeople(uniquePeople);
|
setSharePeople(uniquePeople);
|
||||||
|
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
|
||||||
if (uniquePeople.length > 0) {
|
if (uniquePeople.length > 0) {
|
||||||
setShareSelectedPerson(uniquePeople[0]);
|
setShareSelectedPerson(uniquePeople[0]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const generateShareLink = useCallback(async () => {
|
const generateShareLink = useCallback(async () => {
|
||||||
if (!shareSelectedPerson) return;
|
if (!shareSelectedPerson) {
|
||||||
|
log.warn("[ShareDialog] Attempted to generate link without selected person");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShareGenerating(true);
|
setShareGenerating(true);
|
||||||
setShareCopied(false);
|
setShareCopied(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/share", {
|
const { correlationId, init } = withCorrelation(
|
||||||
method: "POST",
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
credentials: "include",
|
||||||
takenBy: shareSelectedPerson,
|
body: JSON.stringify({
|
||||||
scheduleDays: shareSelectedDays,
|
takenBy: shareSelectedPerson,
|
||||||
}),
|
scheduleDays: shareSelectedDays,
|
||||||
});
|
}),
|
||||||
|
},
|
||||||
|
"fe-share"
|
||||||
|
);
|
||||||
|
const res = await fetch("/api/share", init);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||||
setShareLink(fullUrl);
|
setShareLink(fullUrl);
|
||||||
|
log.info("[ShareDialog] Share link ready", {
|
||||||
|
person: shareSelectedPerson,
|
||||||
|
days: shareSelectedDays,
|
||||||
|
reused: Boolean(data.reused),
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
|
log.error("[ShareDialog] Failed to generate share link", {
|
||||||
|
status: res.status,
|
||||||
|
person: shareSelectedPerson,
|
||||||
|
error: err.error,
|
||||||
|
correlationId,
|
||||||
|
});
|
||||||
alert(err.error || "Failed to generate share link");
|
alert(err.error || "Failed to generate share link");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
|
||||||
alert("Failed to generate share link");
|
alert("Failed to generate share link");
|
||||||
} finally {
|
} finally {
|
||||||
setShareGenerating(false);
|
setShareGenerating(false);
|
||||||
@@ -83,20 +106,53 @@ export function useShare(): UseShareReturn {
|
|||||||
|
|
||||||
const copyShareLink = useCallback(() => {
|
const copyShareLink = useCallback(() => {
|
||||||
if (shareLink) {
|
if (shareLink) {
|
||||||
navigator.clipboard.writeText(shareLink);
|
if (navigator.clipboard?.writeText) {
|
||||||
setShareCopied(true);
|
navigator.clipboard.writeText(shareLink).then(
|
||||||
setTimeout(() => setShareCopied(false), 2000);
|
() => {
|
||||||
|
setShareCopied(true);
|
||||||
|
log.debug("[ShareDialog] Share link copied to clipboard");
|
||||||
|
setTimeout(() => setShareCopied(false), 2000);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Clipboard API blocked (non-secure context / permissions)
|
||||||
|
fallbackCopyToClipboard(shareLink);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fallbackCopyToClipboard(shareLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopyToClipboard(text: string) {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
setShareCopied(true);
|
||||||
|
log.debug("[ShareDialog] Share link copied via fallback");
|
||||||
|
setTimeout(() => setShareCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
log.warn("[ShareDialog] Clipboard copy failed — not in secure context");
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [shareLink]);
|
}, [shareLink]);
|
||||||
|
|
||||||
const closeShareDialog = useCallback(() => {
|
const closeShareDialog = useCallback(() => {
|
||||||
if (showShareDialog) {
|
if (showShareDialog) {
|
||||||
|
log.debug("[ShareDialog] Closing dialog");
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
}, [showShareDialog]);
|
}, [showShareDialog]);
|
||||||
|
|
||||||
// Internal function to reset share dialog state (called by popstate handler)
|
// Internal function to reset share dialog state (called by popstate handler)
|
||||||
const resetShareDialogState = useCallback(() => {
|
const resetShareDialogState = useCallback(() => {
|
||||||
|
log.debug("[ShareDialog] Reset dialog state");
|
||||||
setShowShareDialog(false);
|
setShowShareDialog(false);
|
||||||
setShareLink(null);
|
setShareLink(null);
|
||||||
setShareCopied(false);
|
setShareCopied(false);
|
||||||
|
|||||||
@@ -183,9 +183,16 @@
|
|||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"medicationImage": "Medikamentenbild",
|
"medicationImage": "Medikamentenbild",
|
||||||
"removeImage": "Bild entfernen",
|
"removeImage": "Bild entfernen",
|
||||||
|
"imageUploadErrors": {
|
||||||
|
"tooLarge": "Das Bild ist zu groß. Die maximale Upload-Größe beträgt 10 MB.",
|
||||||
|
"invalidType": "Ungültiger Dateityp. Erlaubte Formate: JPEG, PNG, WebP, GIF.",
|
||||||
|
"invalidImage": "Ungültige oder nicht unterstützte Bilddatei.",
|
||||||
|
"noFile": "Es wurde keine Datei zum Hochladen ausgewählt.",
|
||||||
|
"generic": "Bild-Upload fehlgeschlagen. Bitte versuche es erneut."
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"commercial": "z.B. Ozempic",
|
"commercial": "z.B. Ozempic",
|
||||||
"generic": "z.B. Semaglutid (optional)",
|
"generic": "z.B. Semaglutid",
|
||||||
"takenBy": "Name eingeben und Enter drücken",
|
"takenBy": "Name eingeben und Enter drücken",
|
||||||
"addPerson": "Weitere Person hinzufügen...",
|
"addPerson": "Weitere Person hinzufügen...",
|
||||||
"weight": "z.B. 240",
|
"weight": "z.B. 240",
|
||||||
@@ -351,7 +358,9 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||||
|
"automaticTaken": "Automatisch eingenommen",
|
||||||
"hasNotes": "Hat Notizen",
|
"hasNotes": "Hat Notizen",
|
||||||
|
"hasPrescription": "Rezeptverfolgung aktiviert",
|
||||||
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
||||||
"lightMode": "Zum hellen Modus wechseln",
|
"lightMode": "Zum hellen Modus wechseln",
|
||||||
"darkMode": "Zum dunklen Modus wechseln"
|
"darkMode": "Zum dunklen Modus wechseln"
|
||||||
@@ -427,6 +436,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich",
|
"required": "Dieses Feld ist erforderlich",
|
||||||
|
"nameOrGenericRequired": "Handelsname oder Wirkstoff ist erforderlich",
|
||||||
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
||||||
"tooLong": "{{current}}/{{max}} Zeichen"
|
"tooLong": "{{current}}/{{max}} Zeichen"
|
||||||
},
|
},
|
||||||
@@ -451,6 +461,7 @@
|
|||||||
"loose": "lose",
|
"loose": "lose",
|
||||||
"none": "Kein",
|
"none": "Kein",
|
||||||
"daily": "täglich",
|
"daily": "täglich",
|
||||||
|
"and": "und",
|
||||||
"everyNDays": "alle {{count}} Tage",
|
"everyNDays": "alle {{count}} Tage",
|
||||||
"day": "Tag",
|
"day": "Tag",
|
||||||
"days": "Tage",
|
"days": "Tage",
|
||||||
@@ -648,6 +659,7 @@
|
|||||||
"docPrescriptionExpiry": "Rezeptablauf",
|
"docPrescriptionExpiry": "Rezeptablauf",
|
||||||
"docIntakeHistory": "Einnahme-Verlauf",
|
"docIntakeHistory": "Einnahme-Verlauf",
|
||||||
"docDosesTaken": "Eingenommene Dosen",
|
"docDosesTaken": "Eingenommene Dosen",
|
||||||
|
"docDosesTakenAutomatic": "Automatisch eingenommen",
|
||||||
"docDosesDismissed": "Verworfene Dosen",
|
"docDosesDismissed": "Verworfene Dosen",
|
||||||
"docFirstDose": "Erste Dosis",
|
"docFirstDose": "Erste Dosis",
|
||||||
"docLastDose": "Letzte Dosis",
|
"docLastDose": "Letzte Dosis",
|
||||||
|
|||||||
@@ -183,9 +183,16 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"medicationImage": "Medication Image",
|
"medicationImage": "Medication Image",
|
||||||
"removeImage": "Remove Image",
|
"removeImage": "Remove Image",
|
||||||
|
"imageUploadErrors": {
|
||||||
|
"tooLarge": "Image is too large. Maximum upload size is 10 MB.",
|
||||||
|
"invalidType": "Invalid file type. Allowed formats: JPEG, PNG, WebP, GIF.",
|
||||||
|
"invalidImage": "Invalid or unsupported image file.",
|
||||||
|
"noFile": "No file was selected for upload.",
|
||||||
|
"generic": "Image upload failed. Please try again."
|
||||||
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"commercial": "e.g. Ozempic",
|
"commercial": "e.g. Ozempic",
|
||||||
"generic": "e.g. Semaglutide (optional)",
|
"generic": "e.g. Semaglutide",
|
||||||
"takenBy": "Type name and press Enter",
|
"takenBy": "Type name and press Enter",
|
||||||
"addPerson": "Add another person...",
|
"addPerson": "Add another person...",
|
||||||
"weight": "e.g. 240",
|
"weight": "e.g. 240",
|
||||||
@@ -351,7 +358,9 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Intake reminders enabled",
|
"intakeReminders": "Intake reminders enabled",
|
||||||
|
"automaticTaken": "Automatically taken",
|
||||||
"hasNotes": "Has notes",
|
"hasNotes": "Has notes",
|
||||||
|
"hasPrescription": "Prescription tracking enabled",
|
||||||
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
||||||
"lightMode": "Switch to light mode",
|
"lightMode": "Switch to light mode",
|
||||||
"darkMode": "Switch to dark mode"
|
"darkMode": "Switch to dark mode"
|
||||||
@@ -427,6 +436,7 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "This field is required",
|
"required": "This field is required",
|
||||||
|
"nameOrGenericRequired": "Either commercial name or generic name is required",
|
||||||
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
||||||
"tooLong": "{{current}}/{{max}} characters"
|
"tooLong": "{{current}}/{{max}} characters"
|
||||||
},
|
},
|
||||||
@@ -451,6 +461,7 @@
|
|||||||
"loose": "loose",
|
"loose": "loose",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"daily": "daily",
|
"daily": "daily",
|
||||||
|
"and": "and",
|
||||||
"everyNDays": "every {{count}} days",
|
"everyNDays": "every {{count}} days",
|
||||||
"day": "day",
|
"day": "day",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
@@ -648,6 +659,7 @@
|
|||||||
"docPrescriptionExpiry": "Prescription expiry",
|
"docPrescriptionExpiry": "Prescription expiry",
|
||||||
"docIntakeHistory": "Intake History",
|
"docIntakeHistory": "Intake History",
|
||||||
"docDosesTaken": "Doses taken",
|
"docDosesTaken": "Doses taken",
|
||||||
|
"docDosesTakenAutomatic": "Automatically taken",
|
||||||
"docDosesDismissed": "Doses dismissed",
|
"docDosesDismissed": "Doses dismissed",
|
||||||
"docFirstDose": "First dose",
|
"docFirstDose": "First dose",
|
||||||
"docLastDose": "Last dose",
|
"docLastDose": "Last dose",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||||
|
import { Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import { useModalHistory } from "../hooks";
|
import { useModalHistory } from "../hooks";
|
||||||
|
import { type Coverage, getMedDisplayName } from "../types";
|
||||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +66,7 @@ export function DashboardPage() {
|
|||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
getDayStockStatus,
|
getDayStockStatus,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
clearingMissed,
|
clearingMissed,
|
||||||
@@ -116,7 +119,7 @@ export function DashboardPage() {
|
|||||||
})
|
})
|
||||||
.map((med) => ({
|
.map((med) => ({
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: getMedDisplayName(med),
|
||||||
remainingRefills: med.prescriptionRemainingRefills ?? 0,
|
remainingRefills: med.prescriptionRemainingRefills ?? 0,
|
||||||
threshold: med.prescriptionLowRefillThreshold ?? 1,
|
threshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
}))
|
}))
|
||||||
@@ -248,7 +251,7 @@ export function DashboardPage() {
|
|||||||
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
|
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
|
||||||
<span className="reminder-status-value">
|
<span className="reminder-status-value">
|
||||||
{reminderData.lowStockMeds.map((med, idx) => {
|
{reminderData.lowStockMeds.map((med, idx) => {
|
||||||
const medication = meds.find((m) => m.name === med.name);
|
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
|
||||||
const cov = coverage.all.find((c) => c.name === med.name);
|
const cov = coverage.all.find((c) => c.name === med.name);
|
||||||
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
|
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
|
||||||
const textClass =
|
const textClass =
|
||||||
@@ -320,7 +323,7 @@ export function DashboardPage() {
|
|||||||
(() => {
|
(() => {
|
||||||
const names = reminderData.lastStockSent!.medNames!.split(", ");
|
const names = reminderData.lastStockSent!.medNames!.split(", ");
|
||||||
return names.map((name, idx) => {
|
return names.map((name, idx) => {
|
||||||
const medication = meds.find((m) => m.name === name);
|
const medication = meds.find((m) => getMedDisplayName(m) === name);
|
||||||
return (
|
return (
|
||||||
<span key={name}>
|
<span key={name}>
|
||||||
{idx > 0 && ", "}
|
{idx > 0 && ", "}
|
||||||
@@ -351,7 +354,9 @@ export function DashboardPage() {
|
|||||||
<span className="reminder-status-value">
|
<span className="reminder-status-value">
|
||||||
{reminderData.lastIntakeSent.medName &&
|
{reminderData.lastIntakeSent.medName &&
|
||||||
(() => {
|
(() => {
|
||||||
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
|
const medication = meds.find(
|
||||||
|
(m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName
|
||||||
|
);
|
||||||
return medication ? (
|
return medication ? (
|
||||||
<span
|
<span
|
||||||
className="med-link clickable"
|
className="med-link clickable"
|
||||||
@@ -426,7 +431,7 @@ export function DashboardPage() {
|
|||||||
<p>
|
<p>
|
||||||
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
||||||
{lowStockMeds.map((c, idx) => {
|
{lowStockMeds.map((c, idx) => {
|
||||||
const med = meds.find((m) => m.name === c.name);
|
const med = meds.find((m) => getMedDisplayName(m) === c.name);
|
||||||
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
|
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
status.className === "danger"
|
||||||
@@ -483,7 +488,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
{coverage.all.map((row) => {
|
{coverage.all.map((row) => {
|
||||||
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
|
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
|
||||||
const med = meds.find((m) => m.name === row.name);
|
const med = meds.find((m) => getMedDisplayName(m) === row.name);
|
||||||
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
|
||||||
const textClass =
|
const textClass =
|
||||||
status.className === "danger"
|
status.className === "danger"
|
||||||
@@ -510,7 +515,21 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||||
<span className="med-name-line">
|
<span className="med-name-line">
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
<span
|
||||||
|
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||||
|
</span>
|
||||||
<span className="med-name-block-dash">
|
<span className="med-name-block-dash">
|
||||||
<span className="med-name-text">
|
<span className="med-name-text">
|
||||||
{row.name}
|
{row.name}
|
||||||
@@ -522,6 +541,17 @@ export function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{med?.prescriptionEnabled && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span
|
||||||
|
className="prescription-icon info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.hasPrescription")}
|
||||||
|
>
|
||||||
|
<ClipboardList size={13} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{med?.takenBy && med.takenBy.length > 0 && (
|
{med?.takenBy && med.takenBy.length > 0 && (
|
||||||
<span className="med-taken-by-line">
|
<span className="med-taken-by-line">
|
||||||
@@ -646,7 +676,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
// 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 = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
||||||
return (
|
return (
|
||||||
count +
|
count +
|
||||||
@@ -702,7 +732,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const med = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCov = coverageByMed[item.medName];
|
const medCov = coverageByMed[item.medName];
|
||||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||||
const status = medCov
|
const status = medCov
|
||||||
@@ -728,7 +758,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -767,6 +805,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -786,6 +826,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -941,7 +989,7 @@ export function DashboardPage() {
|
|||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const med = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
@@ -970,7 +1018,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1013,6 +1069,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -1032,6 +1090,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1154,7 +1220,7 @@ export function DashboardPage() {
|
|||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const med = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
@@ -1183,7 +1249,15 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="med-name-stack">
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -1222,6 +1296,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -1241,6 +1317,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
|
||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
|
||||||
|
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
|
||||||
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
import {
|
||||||
|
ConfirmModal,
|
||||||
|
DateInput,
|
||||||
|
FormNumberStepper,
|
||||||
|
Lightbox,
|
||||||
|
MedicationAvatar,
|
||||||
|
MobileEditModal,
|
||||||
|
ReportModal,
|
||||||
|
} from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, Medication } from "../types";
|
import type { DoseUnit, Medication } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
|
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
@@ -31,7 +43,6 @@ export function MedicationsPage() {
|
|||||||
deleteMedImage,
|
deleteMedImage,
|
||||||
uploadingImage,
|
uploadingImage,
|
||||||
existingPeople,
|
existingPeople,
|
||||||
coverage,
|
|
||||||
coverageByMed,
|
coverageByMed,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
@@ -41,6 +52,7 @@ export function MedicationsPage() {
|
|||||||
setForm,
|
setForm,
|
||||||
setOriginalForm,
|
setOriginalForm,
|
||||||
editingId,
|
editingId,
|
||||||
|
setEditingId,
|
||||||
formSaved,
|
formSaved,
|
||||||
setFormSaved,
|
setFormSaved,
|
||||||
formChanged,
|
formChanged,
|
||||||
@@ -66,12 +78,18 @@ export function MedicationsPage() {
|
|||||||
useUnsavedChangesWarning(formChanged);
|
useUnsavedChangesWarning(formChanged);
|
||||||
|
|
||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||||
|
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
|
|
||||||
// Mobile modal state (declared early because it's used in useEffect below)
|
// Mobile modal state (declared early because it's used in useEffect below)
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(pendingEditTransition && window.innerWidth <= 768);
|
||||||
|
const showEditModalRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
showEditModalRef.current = showEditModal;
|
||||||
|
}, [showEditModal]);
|
||||||
const processedEditMedIdRef = useRef<string | null>(null);
|
const processedEditMedIdRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
@@ -122,6 +140,32 @@ export function MedicationsPage() {
|
|||||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
|
||||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||||
|
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handlePendingMedicationImageSelection = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||||
|
setPendingImage(null);
|
||||||
|
setPendingImagePreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUploadError(null);
|
||||||
|
setPendingImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageUploadError(null);
|
||||||
|
}, [editingId]);
|
||||||
const [showObsolete, setShowObsolete] = useState(true);
|
const [showObsolete, setShowObsolete] = useState(true);
|
||||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||||
const [showReportModal, setShowReportModal] = useState(false);
|
const [showReportModal, setShowReportModal] = useState(false);
|
||||||
@@ -157,6 +201,42 @@ export function MedicationsPage() {
|
|||||||
void loadAllMeds();
|
void loadAllMeds();
|
||||||
}, [loadAllMeds]);
|
}, [loadAllMeds]);
|
||||||
|
|
||||||
|
const tryUploadMedImage = useCallback(
|
||||||
|
async (medId: number, file: File) => {
|
||||||
|
setImageUploadError(null);
|
||||||
|
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||||
|
setImageUploadError(t("form.imageUploadErrors.tooLarge"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await uploadMedImage(medId, file);
|
||||||
|
void loadAllMeds();
|
||||||
|
setImageUploadError(null);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = error instanceof Error ? error.message : "UNKNOWN";
|
||||||
|
setImageUploadError(resolveImageUploadError(code, t));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, uploadMedImage, loadAllMeds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUploadMedImage = useCallback(
|
||||||
|
async (medId: number, file: File) => {
|
||||||
|
await tryUploadMedImage(medId, file);
|
||||||
|
},
|
||||||
|
[tryUploadMedImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteMedImage = useCallback(
|
||||||
|
async (medId: number) => {
|
||||||
|
await deleteMedImage(medId);
|
||||||
|
void loadAllMeds();
|
||||||
|
},
|
||||||
|
[deleteMedImage, loadAllMeds]
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate total tablets
|
// Calculate total tablets
|
||||||
const totalTablets = useMemo(() => {
|
const totalTablets = useMemo(() => {
|
||||||
if (form.packageType === "bottle") {
|
if (form.packageType === "bottle") {
|
||||||
@@ -169,6 +249,8 @@ export function MedicationsPage() {
|
|||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
return packCount * blistersPerPack * pillsPerBlister;
|
return packCount * blistersPerPack * pillsPerBlister;
|
||||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||||
|
const decrementValueLabel = t("editStock.decreaseValue");
|
||||||
|
const incrementValueLabel = t("editStock.increaseValue");
|
||||||
|
|
||||||
const dateConsistencyError = useMemo(() => {
|
const dateConsistencyError = useMemo(() => {
|
||||||
const medicationStartDate = form.medicationStartDate;
|
const medicationStartDate = form.medicationStartDate;
|
||||||
@@ -197,6 +279,8 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Open mobile edit modal
|
// Open mobile edit modal
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
|
if (showEditModalRef.current) return;
|
||||||
|
showEditModalRef.current = true;
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
window.history.pushState({ modal: "edit" }, "");
|
window.history.pushState({ modal: "edit" }, "");
|
||||||
}
|
}
|
||||||
@@ -447,7 +531,19 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Upload image if pending (for new medications)
|
// Upload image if pending (for new medications)
|
||||||
if (!editingId && pendingImage && saved.id) {
|
if (!editingId && pendingImage && saved.id) {
|
||||||
await uploadMedImage(saved.id, pendingImage);
|
const uploaded = await tryUploadMedImage(saved.id, pendingImage);
|
||||||
|
if (!uploaded) {
|
||||||
|
// Keep user in edit mode so upload error stays visible and retry is immediate.
|
||||||
|
setEditingId(saved.id);
|
||||||
|
setFormSaved(true);
|
||||||
|
setOriginalForm(form);
|
||||||
|
setPendingImage(null);
|
||||||
|
setPendingImagePreview(null);
|
||||||
|
loadMeds();
|
||||||
|
void loadAllMeds();
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPendingImage(null);
|
setPendingImage(null);
|
||||||
setPendingImagePreview(null);
|
setPendingImagePreview(null);
|
||||||
}
|
}
|
||||||
@@ -588,6 +684,13 @@ export function MedicationsPage() {
|
|||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [showEditModal, closeEditModal]);
|
}, [showEditModal, closeEditModal]);
|
||||||
|
|
||||||
|
function scrollToTopForDesktopEdit() {
|
||||||
|
if (window.innerWidth <= 768) return;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleEditClick(med: Medication) {
|
function handleEditClick(med: Medication) {
|
||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
@@ -595,6 +698,7 @@ export function MedicationsPage() {
|
|||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
scrollToTopForDesktopEdit();
|
||||||
};
|
};
|
||||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
@@ -605,6 +709,7 @@ export function MedicationsPage() {
|
|||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
scrollToTopForDesktopEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewClick(med: Medication) {
|
function handleViewClick(med: Medication) {
|
||||||
@@ -614,6 +719,7 @@ export function MedicationsPage() {
|
|||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
scrollToTopForDesktopEdit();
|
||||||
};
|
};
|
||||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
@@ -624,6 +730,7 @@ export function MedicationsPage() {
|
|||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
scrollToTopForDesktopEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewEntryClick() {
|
function handleNewEntryClick() {
|
||||||
@@ -687,6 +794,9 @@ export function MedicationsPage() {
|
|||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
scrollToTopForDesktopEdit();
|
||||||
|
setPendingEditTransition(false);
|
||||||
|
window.dispatchEvent(new Event("medassist:edit-transition-ready"));
|
||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
@@ -698,6 +808,11 @@ export function MedicationsPage() {
|
|||||||
return allMeds.find((med) => med.id === editingId) ?? null;
|
return allMeds.find((med) => med.id === editingId) ?? null;
|
||||||
}, [allMeds, editingId]);
|
}, [allMeds, editingId]);
|
||||||
|
|
||||||
|
// While navigating from detail modal to edit, render nothing until form is populated
|
||||||
|
if (pendingEditTransition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`med-grid-wrapper${viewMode === "form" ? " desktop-edit-open" : ""}`}>
|
<section className={`med-grid-wrapper${viewMode === "form" ? " desktop-edit-open" : ""}`}>
|
||||||
{/* ── Grid View: always visible medication cards ── */}
|
{/* ── Grid View: always visible medication cards ── */}
|
||||||
@@ -724,19 +839,21 @@ export function MedicationsPage() {
|
|||||||
<span
|
<span
|
||||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
med.imageUrl &&
|
||||||
|
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||||
}
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
|
if (med.imageUrl)
|
||||||
|
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
|
||||||
</span>
|
</span>
|
||||||
<div className="med-name-block">
|
<div className="med-name-block">
|
||||||
<div className="med-name">{med.name}</div>
|
<div className="med-name">{getMedDisplayName(med)}</div>
|
||||||
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
@@ -798,10 +915,12 @@ export function MedicationsPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="med-total">
|
<div className="med-total">
|
||||||
{t("medications.details.stock")}:{" "}
|
{t("medications.details.stock")}:{" "}
|
||||||
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
|
{coverageByMed[getMedDisplayName(med)]
|
||||||
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
{(coverageByMed[med.name]
|
: getPackageSize(med)}{" "}
|
||||||
? Math.round(coverageByMed[med.name].medsLeft)
|
/ {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
||||||
|
{(coverageByMed[getMedDisplayName(med)]
|
||||||
|
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||||
: getPackageSize(med)) > getPackageSize(med) && (
|
: getPackageSize(med)) > getPackageSize(med) && (
|
||||||
<span
|
<span
|
||||||
className="info-tooltip tooltip-align-left warning-text"
|
className="info-tooltip tooltip-align-left warning-text"
|
||||||
@@ -820,8 +939,10 @@ export function MedicationsPage() {
|
|||||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
||||||
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||||
{t("form.blisters.from")} {formatDateTime(s.start)}
|
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||||
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
{"takenBy" in s && (s as import("../types").Intake).takenBy && (
|
||||||
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
<span className="blister-taken-by"> · {(s as import("../types").Intake).takenBy}</span>
|
||||||
|
)}
|
||||||
|
{"intakeRemindersEnabled" in s && (s as import("../types").Intake).intakeRemindersEnabled && (
|
||||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||||
{" "}
|
{" "}
|
||||||
<Bell size={12} aria-hidden="true" />
|
<Bell size={12} aria-hidden="true" />
|
||||||
@@ -856,20 +977,24 @@ export function MedicationsPage() {
|
|||||||
<span
|
<span
|
||||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
med.imageUrl &&
|
||||||
|
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||||
}
|
}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
if (med.imageUrl)
|
if (med.imageUrl)
|
||||||
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
|
setLightboxImage({
|
||||||
|
src: `/api/images/${med.imageUrl}`,
|
||||||
|
alt: getMedDisplayName(med),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
|
||||||
</span>
|
</span>
|
||||||
<div className="med-name-block">
|
<div className="med-name-block">
|
||||||
<div className="med-name">{med.name}</div>
|
<div className="med-name">{getMedDisplayName(med)}</div>
|
||||||
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
@@ -958,15 +1083,6 @@ export function MedicationsPage() {
|
|||||||
>
|
>
|
||||||
{t("form.sections.stock")}
|
{t("form.sections.stock")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === "prescription"}
|
|
||||||
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("prescription")}
|
|
||||||
>
|
|
||||||
{t("form.sections.prescription")}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -976,6 +1092,15 @@ export function MedicationsPage() {
|
|||||||
>
|
>
|
||||||
{t("form.sections.schedule")}
|
{t("form.sections.schedule")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "prescription"}
|
||||||
|
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("prescription")}
|
||||||
|
>
|
||||||
|
{t("form.sections.prescription")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
||||||
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||||
@@ -992,21 +1117,26 @@ export function MedicationsPage() {
|
|||||||
onBlur={() => setShowNameValidation(true)}
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.commercial")}
|
placeholder={t("form.placeholders.commercial")}
|
||||||
maxLength={FIELD_LIMITS.name.max}
|
maxLength={FIELD_LIMITS.name.max}
|
||||||
required={!readOnlyView}
|
|
||||||
/>
|
/>
|
||||||
{!readOnlyView && showNameValidation && fieldErrors.name && (
|
{!readOnlyView && showNameValidation && fieldErrors.name && (
|
||||||
<span className="field-error">{fieldErrors.name}</span>
|
<span className="field-error">{fieldErrors.name}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className={fieldErrors.genericName ? "has-error" : ""}>
|
<label className={!readOnlyView && showNameValidation && fieldErrors.genericName ? "has-error" : ""}>
|
||||||
{t("form.genericName")}
|
{t("form.genericName")}
|
||||||
<input
|
<input
|
||||||
value={form.genericName}
|
value={form.genericName}
|
||||||
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
setForm({ ...form, genericName: e.target.value });
|
||||||
|
}}
|
||||||
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.generic")}
|
placeholder={t("form.placeholders.generic")}
|
||||||
maxLength={FIELD_LIMITS.genericName.max}
|
maxLength={FIELD_LIMITS.genericName.max}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
{!readOnlyView && showNameValidation && fieldErrors.genericName && (
|
||||||
|
<span className="field-error">{fieldErrors.genericName}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.medicationStartDate")}
|
{t("form.medicationStartDate")}
|
||||||
@@ -1023,7 +1153,9 @@ export function MedicationsPage() {
|
|||||||
<select
|
<select
|
||||||
className="package-type-select"
|
className="package-type-select"
|
||||||
value={form.packageType}
|
value={form.packageType}
|
||||||
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleValueChange("packageType", e.target.value as import("../types").PackageType)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
@@ -1085,7 +1217,7 @@ export function MedicationsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="danger icon-only tooltip-trigger"
|
className="danger icon-only tooltip-trigger"
|
||||||
onClick={() => deleteMedImage(editingId)}
|
onClick={() => handleDeleteMedImage(editingId)}
|
||||||
aria-label={t("form.removeImage")}
|
aria-label={t("form.removeImage")}
|
||||||
data-tooltip={t("form.removeImage")}
|
data-tooltip={t("form.removeImage")}
|
||||||
>
|
>
|
||||||
@@ -1098,7 +1230,11 @@ export function MedicationsPage() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) void tryUploadMedImage(editingId, file);
|
||||||
|
}}
|
||||||
disabled={uploadingImage}
|
disabled={uploadingImage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1126,18 +1262,11 @@ export function MedicationsPage() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
onChange={(e) => {
|
onChange={handlePendingMedicationImageSelection}
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setPendingImage(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{imageUploadError && <span className="field-error">{imageUploadError}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* end general tab */}
|
{/* end general tab */}
|
||||||
@@ -1149,32 +1278,32 @@ export function MedicationsPage() {
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.packs")}
|
{t("form.packs")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.packCount}
|
value={form.packCount}
|
||||||
onChange={(e) => handleValueChange("packCount", e.target.value)}
|
onChange={(nextValue) => handleValueChange("packCount", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blistersPerPack")}
|
{t("form.blistersPerPack")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.blistersPerPack}
|
value={form.blistersPerPack}
|
||||||
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
|
onChange={(nextValue) => handleValueChange("blistersPerPack", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.pillsPerBlister")}
|
{t("form.pillsPerBlister")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.pillsPerBlister}
|
value={form.pillsPerBlister}
|
||||||
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
|
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -1186,22 +1315,22 @@ export function MedicationsPage() {
|
|||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
{t("form.totalCapacity")}
|
{t("form.totalCapacity")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.totalPills}
|
value={form.totalPills}
|
||||||
onChange={(e) => handleValueChange("totalPills", e.target.value)}
|
onChange={(nextValue) => handleValueChange("totalPills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.currentPills")}
|
{t("form.currentPills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
value={form.looseTablets}
|
||||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
@@ -1292,32 +1421,32 @@ export function MedicationsPage() {
|
|||||||
<>
|
<>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.authorizedRefills")}
|
{t("prescription.authorizedRefills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionAuthorizedRefills}
|
value={form.prescriptionAuthorizedRefills}
|
||||||
onChange={(e) => handleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
onChange={(nextValue) => handleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.remainingRefills")}
|
{t("prescription.remainingRefills")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionRemainingRefills}
|
value={form.prescriptionRemainingRefills}
|
||||||
onChange={(e) => handleValueChange("prescriptionRemainingRefills", e.target.value)}
|
onChange={(nextValue) => handleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
{t("prescription.lowThreshold")}
|
{t("prescription.lowThreshold")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.prescriptionLowRefillThreshold}
|
value={form.prescriptionLowRefillThreshold}
|
||||||
onChange={(e) => handleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
onChange={(nextValue) => handleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||||
|
min={0}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="prescription-field">
|
<label className="prescription-field">
|
||||||
@@ -1354,22 +1483,24 @@ export function MedicationsPage() {
|
|||||||
<div className="blister-inputs">
|
<div className="blister-inputs">
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.usage")}
|
{t("form.blisters.usage")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={intake.usage}
|
value={intake.usage}
|
||||||
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
|
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
|
||||||
|
min={0.5}
|
||||||
|
step={0.5}
|
||||||
|
allowDecimal={true}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.blisters.everyDays")}
|
{t("form.blisters.everyDays")}
|
||||||
<input
|
<FormNumberStepper
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={intake.every}
|
value={intake.every}
|
||||||
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
|
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
|
||||||
|
min={1}
|
||||||
|
decrementLabel={decrementValueLabel}
|
||||||
|
incrementLabel={incrementValueLabel}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -1478,8 +1609,9 @@ export function MedicationsPage() {
|
|||||||
onRemoveIntake={removeIntake}
|
onRemoveIntake={removeIntake}
|
||||||
onHandleValueChange={handleValueChange}
|
onHandleValueChange={handleValueChange}
|
||||||
meds={allMeds}
|
meds={allMeds}
|
||||||
onUploadMedImage={uploadMedImage}
|
onUploadMedImage={handleUploadMedImage}
|
||||||
onDeleteMedImage={deleteMedImage}
|
onDeleteMedImage={handleDeleteMedImage}
|
||||||
|
imageUploadError={imageUploadError}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closeEditModal();
|
closeEditModal();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { PlannerRow } from "../types";
|
import type { PlannerRow } from "../types";
|
||||||
|
import { getMedDisplayName } from "../types";
|
||||||
import { toInputValue } from "../utils/formatters";
|
import { toInputValue } from "../utils/formatters";
|
||||||
|
|
||||||
// Date helpers
|
// Date helpers
|
||||||
@@ -203,7 +205,8 @@ export function PlannerPage() {
|
|||||||
</div>
|
</div>
|
||||||
{plannerRows.map((row) => {
|
{plannerRows.map((row) => {
|
||||||
const med =
|
const med =
|
||||||
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
|
meds.find((m) => m.id === row.medicationId) ||
|
||||||
|
meds.find((m) => getMedDisplayName(m) === row.medicationName);
|
||||||
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import type { Coverage } from "../types";
|
||||||
|
import { getMedDisplayName } from "../types";
|
||||||
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
@@ -66,6 +68,7 @@ export function SchedulePage() {
|
|||||||
pastDays,
|
pastDays,
|
||||||
futureDays,
|
futureDays,
|
||||||
takenDoses,
|
takenDoses,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
@@ -114,7 +117,7 @@ export function SchedulePage() {
|
|||||||
|
|
||||||
// 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 = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
||||||
return (
|
return (
|
||||||
count +
|
count +
|
||||||
@@ -169,7 +172,7 @@ export function SchedulePage() {
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
day.meds.map((item) => {
|
day.meds.map((item) => {
|
||||||
const med = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const medCov = coverageByMed[item.medName];
|
const medCov = coverageByMed[item.medName];
|
||||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
@@ -212,6 +215,8 @@ export function SchedulePage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -231,6 +236,14 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -321,7 +334,7 @@ export function SchedulePage() {
|
|||||||
{day.meds.map((item) => {
|
{day.meds.map((item) => {
|
||||||
const medCoverage = coverageByMed[item.medName];
|
const medCoverage = coverageByMed[item.medName];
|
||||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||||
const med = meds.find((m) => m.name === item.medName);
|
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||||
const depletionTime = depletionByMed[item.medName];
|
const depletionTime = depletionByMed[item.medName];
|
||||||
// Check if this dose is scheduled after medication runs out
|
// Check if this dose is scheduled after medication runs out
|
||||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||||
@@ -373,6 +386,8 @@ export function SchedulePage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -396,6 +411,11 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, ExportModal } from "../components";
|
import { ConfirmModal, ExportModal } from "../components";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Coverage } from "../types";
|
import type { Coverage, PackageType } from "../types";
|
||||||
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
@@ -43,9 +43,12 @@ export function getMedTotal(med: {
|
|||||||
pillsPerBlister: number;
|
pillsPerBlister: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
stockAdjustment?: number | null;
|
stockAdjustment?: number | null;
|
||||||
packageType?: string;
|
packageType?: PackageType;
|
||||||
}): number {
|
}): number {
|
||||||
return getMedTotalFromTypes(med);
|
return getMedTotalFromTypes({
|
||||||
|
...med,
|
||||||
|
stockAdjustment: med.stockAdjustment ?? undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReminderStatusData(
|
export function getReminderStatusData(
|
||||||
|
|||||||
+59
-13
@@ -108,6 +108,22 @@ body.modal-open {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-transition-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 140ms ease-out;
|
||||||
|
z-index: 1500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-transition-mask.active {
|
||||||
|
transition: none;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
background: linear-gradient(135deg, rgba(67, 106, 255, 0.08), rgba(115, 195, 255, 0.06));
|
||||||
border: 1px solid var(--border-primary);
|
border: 1px solid var(--border-primary);
|
||||||
@@ -1127,11 +1143,15 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
.blister-row .blister-inputs {
|
.blister-row .blister-inputs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.05fr) minmax(10.75rem, 1fr) minmax(7.25rem, 0.8fr);
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blister-row .blister-inputs > label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.blister-row .blister-inputs label.taken-by-field {
|
.blister-row .blister-inputs label.taken-by-field {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1174,17 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop edit sidebar can be narrow; avoid clipping right-side controls. */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.edit-sidebar .blister-row .blister-inputs {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-sidebar .blister-row .blister-inputs label.taken-by-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.gap {
|
.gap {
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
@@ -2212,6 +2243,9 @@ button.has-validation-error {
|
|||||||
.time-main .med-name span.clickable {
|
.time-main .med-name span.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.time-main .med-name .med-name-stack.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.time-main .med-name span.clickable:hover .med-avatar {
|
.time-main .med-name span.clickable:hover .med-avatar {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
@@ -3373,7 +3407,7 @@ button.has-validation-error {
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.15s,
|
opacity 0.15s,
|
||||||
visibility 0.15s;
|
visibility 0.15s;
|
||||||
z-index: 100;
|
z-index: 1100;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3391,7 +3425,7 @@ button.has-validation-error {
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.15s,
|
opacity 0.15s,
|
||||||
visibility 0.15s;
|
visibility 0.15s;
|
||||||
z-index: 101;
|
z-index: 1101;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
||||||
@@ -4332,7 +4366,7 @@ button.has-validation-error {
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-modal .med-detail-body {
|
.med-detail-modal .med-detail-body {
|
||||||
@@ -4379,6 +4413,7 @@ button.has-validation-error {
|
|||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.taken-by-badge {
|
.taken-by-badge {
|
||||||
@@ -4531,7 +4566,7 @@ button.has-validation-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-body {
|
.med-detail-body {
|
||||||
padding: 1.5rem 2rem 0;
|
padding: 1.5rem 2rem 2rem;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4605,9 +4640,6 @@ button.has-validation-error {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prescription-detail-grid .med-detail-value {
|
|
||||||
}
|
|
||||||
|
|
||||||
.med-detail-item {
|
.med-detail-item {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -4650,8 +4682,8 @@ button.has-validation-error {
|
|||||||
.med-schedule-item {
|
.med-schedule-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 0.35rem 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -4665,22 +4697,26 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.med-schedule-freq {
|
.med-schedule-freq {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-time {
|
.med-schedule-time {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-person {
|
.med-schedule-person {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-schedule-bell {
|
.med-schedule-bell {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .med-schedule-bell {
|
[data-theme="light"] .med-schedule-bell {
|
||||||
@@ -4697,7 +4733,7 @@ button.has-validation-error {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||||
@@ -4993,7 +5029,8 @@ button.has-validation-error {
|
|||||||
|
|
||||||
/* Reminder icon indicator */
|
/* Reminder icon indicator */
|
||||||
.reminder-icon.info-tooltip,
|
.reminder-icon.info-tooltip,
|
||||||
.notes-icon.info-tooltip {
|
.notes-icon.info-tooltip,
|
||||||
|
.prescription-icon.info-tooltip {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -5008,6 +5045,10 @@ button.has-validation-error {
|
|||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prescription-icon.info-tooltip {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.reminder-icon.info-tooltip,
|
.reminder-icon.info-tooltip,
|
||||||
.blister-reminder-icon {
|
.blister-reminder-icon {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
@@ -5022,8 +5063,13 @@ button.has-validation-error {
|
|||||||
color: #1d4ed8; /* darker blue — strong contrast on light backgrounds */
|
color: #1d4ed8; /* darker blue — strong contrast on light backgrounds */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .prescription-icon.info-tooltip {
|
||||||
|
color: #047857; /* dark emerald — strong contrast on light backgrounds */
|
||||||
|
}
|
||||||
|
|
||||||
.reminder-icon.info-tooltip:hover,
|
.reminder-icon.info-tooltip:hover,
|
||||||
.notes-icon.info-tooltip:hover {
|
.notes-icon.info-tooltip:hover,
|
||||||
|
.prescription-icon.info-tooltip:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -446,7 +446,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refill-number-stepper input {
|
.refill-number-stepper input {
|
||||||
order: initial;
|
order: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -460,21 +460,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refill-number-stepper .stepper-btn.decrement {
|
.refill-number-stepper .stepper-btn.decrement {
|
||||||
order: initial;
|
order: -1;
|
||||||
|
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refill-number-stepper .stepper-btn.increment {
|
.refill-number-stepper .stepper-btn.increment {
|
||||||
order: initial;
|
order: 1;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-left: 1px solid var(--border-primary);
|
border-left: 1px solid var(--border-primary);
|
||||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
|
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||||
filter: none;
|
filter: none;
|
||||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
|
}
|
||||||
|
|
||||||
|
.refill-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.refill-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
@media (min-width: 641px) {
|
||||||
@@ -488,12 +496,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
|
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
|
||||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
background: color-mix(in srgb, #dc2626 18%, white);
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
|
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
|
||||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
background: color-mix(in srgb, #0f766e 18%, white);
|
||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,6 +512,111 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form stepper keeps symmetric - value + layout in all contexts (desktop/mobile). */
|
||||||
|
.form-number-stepper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper input {
|
||||||
|
order: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-right: 1px solid var(--border-primary);
|
||||||
|
border-left: none;
|
||||||
|
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn.decrement {
|
||||||
|
order: -1;
|
||||||
|
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn.increment {
|
||||||
|
order: 1;
|
||||||
|
border-right: none;
|
||||||
|
border-left: 1px solid var(--border-primary);
|
||||||
|
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper .stepper-btn.increment:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight both controls when the center value field is focused (keyboard/click). */
|
||||||
|
.form-number-stepper:has(input:focus) .stepper-btn.decrement:not(:disabled),
|
||||||
|
.form-number-stepper:has(input:focus-visible) .stepper-btn.decrement:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper:has(input:focus) .stepper-btn.increment:not(:disabled),
|
||||||
|
.form-number-stepper:has(input:focus-visible) .stepper-btn.increment:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dense schedule grids need a compact variant so the middle value stays visible. */
|
||||||
|
.blister-inputs .form-number-stepper,
|
||||||
|
.mobile-edit-form .blister-row .form-number-stepper {
|
||||||
|
grid-template-columns: 2.35rem minmax(2rem, 1fr) 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blister-inputs .form-number-stepper input,
|
||||||
|
.mobile-edit-form .blister-row .form-number-stepper input {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.5rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blister-inputs .form-number-stepper .stepper-btn,
|
||||||
|
.mobile-edit-form .blister-row .form-number-stepper .stepper-btn {
|
||||||
|
min-width: 2.35rem;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
.form-number-stepper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-number-stepper input {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .form-number-stepper .stepper-btn.decrement {
|
||||||
|
background: color-mix(in srgb, #dc2626 18%, white);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .form-number-stepper .stepper-btn.increment {
|
||||||
|
background: color-mix(in srgb, #0f766e 18%, white);
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-number-stepper {
|
||||||
|
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.edit-stock-summary {
|
.edit-stock-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -284,24 +284,6 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("lightbox-open-med-image.png")).toBeInTheDocument();
|
expect(screen.getByText("lightbox-open-med-image.png")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles Escape key with modal priority", () => {
|
|
||||||
appContextMock.scheduleLightboxImage = "med-image.png";
|
|
||||||
appContextMock.showImageLightbox = true;
|
|
||||||
appContextMock.showShareDialog = true;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
|
|
||||||
expect(appContextMock.closeScheduleLightbox).toHaveBeenCalled();
|
|
||||||
expect(appContextMock.closeImageLightbox).not.toHaveBeenCalled();
|
|
||||||
expect(appContextMock.closeShareDialog).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles popstate by closing selected medication", () => {
|
it("handles popstate by closing selected medication", () => {
|
||||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
||||||
|
|
||||||
@@ -344,20 +326,6 @@ describe("App", () => {
|
|||||||
expect(window.history.pushState).toHaveBeenCalled();
|
expect(window.history.pushState).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape key closes about modal via history back", () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "open-about" }));
|
|
||||||
expect(screen.getByText("about-modal-open")).toBeInTheDocument();
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
expect(window.history.back).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles popstate by resetting share dialog state", () => {
|
it("handles popstate by resetting share dialog state", () => {
|
||||||
appContextMock.showShareDialog = true;
|
appContextMock.showShareDialog = true;
|
||||||
|
|
||||||
@@ -381,47 +349,6 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape closes refill modal when it is topmost", () => {
|
|
||||||
appContextMock.showRefillModal = true;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
expect(appContextMock.closeRefillModal).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Escape closes edit stock modal when it is topmost", () => {
|
|
||||||
appContextMock.showEditStockModal = true;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
expect(appContextMock.closeEditStockModal).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Escape closes user filter and medication detail in lower priority", () => {
|
|
||||||
appContextMock.selectedUser = "Max";
|
|
||||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
expect(appContextMock.closeUserFilter).toHaveBeenCalled();
|
|
||||||
expect(appContextMock.closeMedDetail).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("popstate closes image lightbox before other modals", () => {
|
it("popstate closes image lightbox before other modals", () => {
|
||||||
appContextMock.showImageLightbox = true;
|
appContextMock.showImageLightbox = true;
|
||||||
appContextMock.scheduleLightboxImage = "img.png";
|
appContextMock.scheduleLightboxImage = "img.png";
|
||||||
@@ -450,17 +377,4 @@ describe("App", () => {
|
|||||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
expect(appContextMock.setScheduleLightboxImage).toHaveBeenCalledWith(null);
|
expect(appContextMock.setScheduleLightboxImage).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape closes medication detail when no higher-priority modal is open", () => {
|
|
||||||
appContextMock.selectedMed = { id: 1, packCount: 1, looseTablets: 0, updatedAt: null };
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
|
||||||
<App />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
||||||
expect(appContextMock.closeMedDetail).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("AppHeader", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
needsSetup: false,
|
needsSetup: false,
|
||||||
}),
|
}),
|
||||||
@@ -171,7 +171,7 @@ describe("AppHeader", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
needsSetup: false,
|
needsSetup: false,
|
||||||
}),
|
}),
|
||||||
@@ -205,7 +205,7 @@ describe("AppHeader", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
needsSetup: false,
|
needsSetup: false,
|
||||||
}),
|
}),
|
||||||
@@ -239,7 +239,7 @@ describe("AppHeader", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
needsSetup: false,
|
needsSetup: false,
|
||||||
}),
|
}),
|
||||||
@@ -322,7 +322,7 @@ describe("AppHeader", () => {
|
|||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
oidcProviderName: "",
|
oidcProviderName: "",
|
||||||
hasUsers: true,
|
hasUsers: true,
|
||||||
@@ -370,10 +370,13 @@ describe("AppHeader", () => {
|
|||||||
fireEvent.click(userMenuBtn);
|
fireEvent.click(userMenuBtn);
|
||||||
fireEvent.click(screen.getByText(/auth\.signOut/i));
|
fireEvent.click(screen.getByText(/auth\.signOut/i));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/auth/logout", {
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
method: "POST",
|
"/api/auth/logout",
|
||||||
credentials: "include",
|
expect.objectContaining({
|
||||||
});
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe("AuthProvider", () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ describe("AuthProvider", () => {
|
|||||||
renderHook(() => useAuth(), { wrapper });
|
renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ describe("AuthProvider", () => {
|
|||||||
|
|
||||||
// Wait for the initial fetch to complete
|
// Wait for the initial fetch to complete
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
|
expect(fetch).toHaveBeenCalledWith("/api/auth/state", expect.anything());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a bit more to ensure no additional calls happen
|
// Wait a bit more to ensure no additional calls happen
|
||||||
@@ -79,7 +79,7 @@ describe("AuthProvider", () => {
|
|||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }),
|
json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ ok: false, status: 401 })
|
.mockResolvedValueOnce({ ok: false, status: 401 })
|
||||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||||
@@ -94,18 +94,21 @@ describe("AuthProvider", () => {
|
|||||||
const response = await result.current.authFetch("/api/medications", { method: "GET" });
|
const response = await result.current.authFetch("/api/medications", { method: "GET" });
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
expect(fetch).toHaveBeenNthCalledWith(2, "/api/medications", {
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
method: "GET",
|
2,
|
||||||
credentials: "include",
|
"/api/medications",
|
||||||
});
|
expect.objectContaining({ method: "GET", credentials: "include" })
|
||||||
expect(fetch).toHaveBeenNthCalledWith(3, "/api/auth/refresh", {
|
);
|
||||||
method: "POST",
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
credentials: "include",
|
3,
|
||||||
});
|
"/api/auth/refresh",
|
||||||
expect(fetch).toHaveBeenNthCalledWith(4, "/api/medications", {
|
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||||
method: "GET",
|
);
|
||||||
credentials: "include",
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
});
|
4,
|
||||||
|
"/api/medications",
|
||||||
|
expect.objectContaining({ method: "GET", credentials: "include" })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("authFetch logs user out when refresh fails", async () => {
|
it("authFetch logs user out when refresh fails", async () => {
|
||||||
@@ -113,7 +116,7 @@ describe("AuthProvider", () => {
|
|||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1, username: "tester" }) })
|
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1, username: "tester" }) })
|
||||||
.mockResolvedValueOnce({ ok: false, status: 401 })
|
.mockResolvedValueOnce({ ok: false, status: 401 })
|
||||||
@@ -138,7 +141,7 @@ describe("AuthProvider", () => {
|
|||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "timer-user" }) })
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "timer-user" }) })
|
||||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||||
@@ -164,7 +167,7 @@ describe("AuthProvider", () => {
|
|||||||
describe("LoginForm", () => {
|
describe("LoginForm", () => {
|
||||||
const mockAuthState = {
|
const mockAuthState = {
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
hasUsers: true,
|
hasUsers: true,
|
||||||
@@ -278,7 +281,7 @@ describe("LoginForm", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
hasUsers: true,
|
hasUsers: true,
|
||||||
@@ -314,7 +317,7 @@ describe("LoginForm", () => {
|
|||||||
describe("RegisterForm", () => {
|
describe("RegisterForm", () => {
|
||||||
const mockAuthState = {
|
const mockAuthState = {
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
@@ -401,7 +404,7 @@ describe("RegisterForm", () => {
|
|||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
hasUsers: false,
|
hasUsers: false,
|
||||||
@@ -436,7 +439,7 @@ describe("RegisterForm", () => {
|
|||||||
describe("AuthPage", () => {
|
describe("AuthPage", () => {
|
||||||
const mockAuthState = {
|
const mockAuthState = {
|
||||||
authEnabled: true,
|
authEnabled: true,
|
||||||
localAuthEnabled: true,
|
formLoginEnabled: true,
|
||||||
oidcEnabled: false,
|
oidcEnabled: false,
|
||||||
registrationEnabled: true,
|
registrationEnabled: true,
|
||||||
hasUsers: true,
|
hasUsers: true,
|
||||||
@@ -501,7 +504,7 @@ describe("UserProfile", () => {
|
|||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
|
json: () => Promise.resolve({ authEnabled: true, formLoginEnabled: true }),
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -721,7 +724,7 @@ describe("AuthProvider methods", () => {
|
|||||||
it("refreshUser retries after token refresh on 401", async () => {
|
it("refreshUser retries after token refresh on 401", async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }) })
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) })
|
||||||
.mockResolvedValueOnce({ ok: false, status: 401 })
|
.mockResolvedValueOnce({ ok: false, status: 401 })
|
||||||
.mockResolvedValueOnce({ ok: true })
|
.mockResolvedValueOnce({ ok: true })
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "refreshed-user" }) });
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "refreshed-user" }) });
|
||||||
@@ -893,7 +896,7 @@ describe("AuthProvider methods", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
|
||||||
await expect(result.current.uploadAvatar(file)).rejects.toThrow("Upload failed");
|
await expect(result.current.uploadAvatar(file)).rejects.toThrow("UNKNOWN");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteAvatar succeeds and refreshes user", async () => {
|
it("deleteAvatar succeeds and refreshes user", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user