Compare commits

...

40 Commits

Author SHA1 Message Date
Daniel Volz 5d657558f7 chore: release v1.14.4 (#272) 2026-02-22 14:00:02 +01:00
Daniel Volz 0c28999c89 chore: release v1.14.3 (#271) 2026-02-22 11:05:09 +01:00
Daniel Volz 2296303236 fix: prevent duplicate scheduler reminder sends (#270) 2026-02-22 10:56:13 +01:00
Daniel Volz 9a2d42b8b9 fix: stabilize dashboard modal and image click behavior (#267)
* feat: make medication names clickable in Dashboard dose schedule

Add click handlers to med-name-stack divs in all three dose schedule
sections (past, current/overdue, future) on DashboardPage, opening the
MedDetail modal on click.

Add early-return guards to all four modal openers in AppContext
(openMedDetail, openImageLightbox, openScheduleLightbox, openUserFilter)
to prevent duplicate pushState entries on double-click, which caused
unexpected navigation to the Medications page.

Closes #266

* fix: stabilize dashboard modal and image click handling

* fix: close medication detail on first backdrop click
2026-02-22 10:50:58 +01:00
Daniel Volz 088a6c1a05 chore: fix all Biome lint warnings and MedDetail intake bell icons (#265)
- Backend: refactor nested ternaries, remove unused imports/any types
- Frontend: fix exhaustive deps, a11y label associations, array index keys,
  empty CSS blocks, unused vars, type annotations
- MedDetail modal: fix intake schedule bell icons not rendering (use unified
  intake source with fallback), place bell inline after person name
- MedDetail modal: revert schedule rows from grid to flexbox layout

Closes #264
2026-02-22 08:52:03 +01:00
Daniel Volz 228fd4cd7e chore: release v1.14.2 (#263) 2026-02-21 20:56:12 +01:00
Daniel Volz e346d60f39 chore: release v1.14.1 (#262) 2026-02-21 20:51:28 +01:00
Daniel Volz afb8e5028c fix: auto-mark intakes at due time and show robot marker (#261)
* fix: auto-mark intakes at due time and show robot marker

* test: add taken_source to integration schema

* test: align e2e route schema with taken_source
2026-02-21 20:45:05 +01:00
Daniel Volz 9ab077a037 chore: release v1.14.0 (#259) 2026-02-21 18:04:20 +01:00
Daniel Volz 976d7356ec feat: improve medication detail modal layout and display (#258)
Widen detail modal on desktop (711px, up from 500px) with max-width
override to beat modals-base.css specificity. Limit fullscreen mode
to actual phones (<=500px) instead of all screens <=900px. Move intake
schedule section before prescription details. Show per-intake takenBy
person and bell icon with proper warning color. Right-align time in
schedule rows. Move notes icon after label text. Replace emoji bell
icons with Lucide Bell component in SchedulePage and MobileEditModal.
Add common.on/common.off i18n keys.

Closes #254
2026-02-21 18:00:23 +01:00
Daniel Volz 943148fb49 feat: close modals with browser back button on mobile (#257)
* feat: close modals with browser back button on mobile

Create reusable useModalHistory hook that pushes history state when a
modal opens and listens for popstate to close it. Apply to ReportModal,
ClearMissedConfirm, ExportModal, ImportConfirm, and all modals using
ConfirmModal/ShareDialog/Auth/ExportModal base components. Escape key
handling was already in place for desktop.

Closes #253

* fix: update tests for renamed button labels and missing useModalHistory mock
2026-02-21 18:00:12 +01:00
Daniel Volz 94bd8bd6e8 feat: improve mobile edit modal swipe gestures and tab navigation (#256)
* feat: improve mobile edit modal swipe gestures and tab navigation

Replace React passive touch handlers with native non-passive
addEventListener via useEffect for reliable horizontal swipe blocking.
Reduce axis-lock threshold from 18-26px to 6px for more responsive
gesture detection. Remove isInteractive() guard so swipe works on
input fields. Add tab strip auto-scroll via scrollIntoView when
active tab changes. Fix vertical scrolling by changing readonly
fieldset from display:block to display:flex.

Closes #252

* fix: guard scrollIntoView for jsdom test compatibility
2026-02-21 18:00:02 +01:00
Daniel Volz 0cf1c5353e fix: notification channel toggles snap back after being enabled (#255)
* fix: notification channel toggles snap back after being enabled

The checked props for email/push notification toggles had redundant
conditions (smtpHost/shoutrrrUrl checks) that forced them to false,
causing immediate visual snap-back. Additionally, performSave()
overwrote emailEnabled/shoutrrrEnabled in local state with effective
values, disabling toggles when no SMTP host or Shoutrrr URL was set.

Remove redundant checked prop conditions (disabled attr already handles
interaction gating) and stop overwriting enabled flags in local state
after save.

Closes #250

* fix: remove leaked useModalHistory import from SettingsPage

* fix: update useSettings tests to match new toggle behavior
2026-02-21 17:59:50 +01:00
github-actions[bot] 98cf1ce1d2 chore: update test count badges [skip ci] 2026-02-21 14:51:05 +00:00
Daniel Volz 75c201cab5 fix: keep med detail stock and package values consistent (#249) 2026-02-21 15:47:44 +01:00
github-actions[bot] 74f079d13e chore: update test count badges [skip ci] 2026-02-21 14:28:27 +00:00
Daniel Volz fd3b770a81 fix: improve mobile edit modal scrolling behavior (#247) 2026-02-21 15:24:57 +01:00
Daniel Volz 612aa007aa fix: unify stock semantics across planner and scheduler (#245)
* fix: unify stock semantics across planner and scheduler

* fix: stabilize dashboard hmr and align stock helper tests
2026-02-21 15:24:53 +01:00
Daniel Volz 02af93ec55 chore: release v1.13.0 (#243) 2026-02-20 19:55:26 +01:00
dependabot[bot] 8f57aa8bc9 build(deps): bump ajv from 8.17.1 to 8.18.0 in /backend (#238)
Bumps [ajv](https://github.com/ajv-validator/ajv) from 8.17.1 to 8.18.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v8.17.1...v8.18.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 8.18.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:51:41 +01:00
dependabot[bot] f42ed87d94 build(deps): bump minimatch from 10.2.0 to 10.2.2 in /backend (#237)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.0 to 10.2.2.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v10.2.0...v10.2.2)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 10.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:51:37 +01:00
Daniel Volz 8de54b9065 docs: sync README feature list with recent app changes (#241) 2026-02-20 19:50:55 +01:00
Daniel Volz b489e1e117 fix: keep mobile med detail actions visible while scrolling (#240) 2026-02-20 19:31:59 +01:00
Daniel Volz 8c97abd3c9 Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  chore: update test count badges [skip ci]
2026-02-20 18:58:44 +01:00
Daniel Volz 2eec415af6 docs: enforce hard PR scope and size splitting rule in copilot instructions 2026-02-20 18:56:55 +01:00
github-actions[bot] 243a46f960 chore: update test count badges [skip ci] 2026-02-20 17:56:42 +00:00
Daniel Volz 052751b2ba feat: reports, timeline toggles, and stock correction improvements (#236)
* refactor(frontend): modularize styles and polish modal/ui interactions

* feat: add report workflow and timeline/settings improvements

* fix: resolve CI failures for backend typing, lint, and playwright config
2026-02-20 18:52:59 +01:00
Daniel Volz 89d565bc9d chore: fix lint errors and reduce warnings across codebase (#234)
* chore: fix lint errors and reduce warnings across codebase

- Fix noExplicitAny catches in backend routes and plugins
- Fix noNestedTernary issues in backend services
- Add keyboard event handlers for useKeyWithClickEvents in frontend
- Disable noImportantStyles rule in biome.json
- Fix formatting errors across all changed files
- Fix test file lint issues

Closes #233

* fix: restore any types in test files for TS compatibility

* fix: revert Auth.tsx dependency array changes that caused infinite re-render

* fix: null-safe user.username access in AppContext dependency array
2026-02-17 05:21:47 +01:00
Daniel Volz 08a18fc14a fix: improve export filename and import confirmation UX (#232)
Export filename:
- Include username for multi-user/instance distinction
- Include timestamp with time (YYYYMMDD-HHMM) instead of date only
- Example: medassist-export-daniel-20260216-2108.json

Import confirmation:
- Show friendly 'Import Data?' dialog on empty instances instead of
  scary 'Replace All Data?' warning with danger button
- Only show destructive warning when there is existing data to replace
- Use primary button style for empty-state import

Closes #231
2026-02-16 22:20:20 +01:00
Daniel Volz e41efdf98b fix: disable nginx temp file buffering for proxied responses (#230)
Replace increased proxy buffer sizes with proxy_max_temp_file_size 0
to stream upstream responses directly to clients instead of buffering
to temp files. Eliminates warnings for large medication images without
increasing per-connection RAM usage.
2026-02-16 22:03:11 +01:00
Daniel Volz cefac8cc4e fix: nginx proxy buffering warnings and LOG_LEVEL propagation (#229)
- Increase proxy buffer sizes to prevent upstream image responses being
  buffered to temporary files (16k header + 8x256k body + 512k busy)
- Add env_file to frontend service in docker-compose.dev.yml for LOG_LEVEL
- Normalize LOG_LEVEL in nginx-entrypoint.sh (case-insensitive, trim whitespace)
- Add startup logging showing LOG_LEVEL → access_log mapping

Closes #226
2026-02-16 21:52:03 +01:00
Daniel Volz 779870960c fix: frontend UI polish — tooltips, planner checkbox, settings layout (#228)
- Fix mobile tooltip positioning (above icon instead of centered)
- Place planner checkbox and send-now button on same row
- Move settings tooltips beside input fields instead of overlapping
- Fix input-with-tooltip layout for narrow screens
- Add daily/everyNDays i18n keys for dose frequency display
- Fix lint formatting in page components

Closes #225
2026-02-16 21:51:51 +01:00
Daniel Volz 871e6066ec fix: export/import missing refill history, prescription, and bottle fields (#227)
- Add refill history export/import with medication reference mapping
- Include totalPills (bottle type capacity) in inventory export
- Include dismissedUntil field for past dose dismissal state
- Add expiryWarningDays and shareStockStatus to settings export
- Bump export version to 1.1
- Add refill count to import result reporting
- Update i18n import success details to include refill count

Closes #224
2026-02-16 21:51:39 +01:00
dependabot[bot] ff100dfea5 build(deps-dev): bump @types/nodemailer in /backend (#223)
Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 6.4.21 to 7.0.10.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: "@types/nodemailer"
  dependency-version: 7.0.10
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:28 +01:00
dependabot[bot] 47581ca7ad build(deps-dev): bump @biomejs/biome (#222)
Bumps the minor-and-patch group in /backend with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.3.15 to 2.4.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.1/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:18 +01:00
dependabot[bot] 39e9ebbf28 build(deps): bump the minor-and-patch group in /frontend with 3 updates (#221)
Bumps the minor-and-patch group in /frontend with 3 updates: [i18next](https://github.com/i18next/i18next), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [jsdom](https://github.com/jsdom/jsdom).


Updates `i18next` from 25.8.7 to 25.8.10
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.8.7...v25.8.10)

Updates `@biomejs/biome` from 2.3.15 to 2.4.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.1/packages/@biomejs/biome)

Updates `jsdom` from 28.0.0 to 28.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/28.0.0...28.1.0)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 28.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:06 +01:00
dependabot[bot] 41b20bb4e6 build(deps): bump actions/github-script from 7 to 8 (#220)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:05:55 +01:00
dependabot[bot] f9c51956d5 build(deps-dev): bump @biomejs/biome from 2.3.15 to 2.4.1 (#219)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.3.15 to 2.4.1.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.1/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:05:41 +01:00
Daniel Volz 543b42b540 docs: add mandatory PR metadata fields to release-manager agent (#218)
- Add PR Metadata section with assignee, label, and project requirements
- Update gh pr create command template to include --assignee, --label, --project flags
- Add label mapping table for branch prefix to label type
- Update workflow summary to mention metadata fields
2026-02-15 23:52:15 +01:00
Daniel Volz 36a2f7d537 chore: release v1.12.0 (#216) 2026-02-15 23:28:35 +01:00
135 changed files with 13009 additions and 6455 deletions
+35 -5
View File
@@ -89,6 +89,29 @@ PR #141: "fix: planner checkbox layout on single line"
--- ---
## PR Metadata (MANDATORY)
Every Pull Request MUST have the following sidebar fields populated at creation time:
| Field | Value | How |
|-------|-------|-----|
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
**Label mapping for PRs:**
| Branch prefix / commit type | Label |
|---|---|
| `feat/` | `enhancement` |
| `fix/` | `bug` |
| `docs/` | `documentation` |
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
| `chore/release-*` | No label needed (release PRs are automated) |
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
---
## Task 1: Branch, PR, and Merge Workflow ## Task 1: Branch, PR, and Merge Workflow
When code changes (features or bug fixes) are complete: When code changes (features or bug fixes) are complete:
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
```bash ```bash
git push -u origin feat/short-description git push -u origin feat/short-description
``` ```
2. Create a Pull Request via GitHub CLI, linking the related issue: 2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
```bash ```bash
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER> gh pr create \
--title "fix: short description" \
--body "Closes #<ISSUE_NUMBER>
Description of changes" Description of changes" \
--assignee DanielVolz \
--label bug \
--project "@DanielVolz's MedAssist-ng project"
``` ```
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge. - Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
- The `--project` flag links the PR to the Project board.
3. **Present the PR URL to the user and wait for confirmation.** 3. **Present the PR URL to the user and wait for confirmation.**
### Step 4: Wait for CI and Merge ### Step 4: Wait for CI and Merge
@@ -462,7 +492,7 @@ Code complete & validated by testing-manager
1. Ensure a GitHub issue exists (create if not) 1. Ensure a GitHub issue exists (create if not)
2. Create feature branch (fix/... or feat/...) 2. Create feature branch (fix/... or feat/...)
3. Commit, push, create PR (with "Closes #N" in body) 3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
4. Wait for CI (all required checks) 4. Wait for CI (all required checks)
5. Merge PR to main (squash + delete branch) 5. Merge PR to main (squash + delete branch)
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6) 6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
+3 -2
View File
@@ -15,6 +15,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. - **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior. - **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs. - **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready. - **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested. - **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
@@ -67,8 +68,8 @@ cd frontend && npm run build
```bash ```bash
cd frontend && npm run test:e2e cd frontend && npm run test:e2e
cd frontend && npm run test:e2e -- --project=chromium cd frontend && npm run test:e2e -- --project=chromium
cd frontend && npm run test:e2e:ui # Never use interactive UI/headed/report-server commands in agent runs.
cd frontend && npm run test:e2e:headed # Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
``` ```
## Backend Test Patterns ## Backend Test Patterns
+1
View File
@@ -28,6 +28,7 @@ Use this orientation for quick navigation before applying the rules below.
- Testing work belongs to `@testing-manager`. - Testing work belongs to `@testing-manager`.
- PR/release/CI orchestration belongs to `@release-manager`. - PR/release/CI orchestration belongs to `@release-manager`.
- Keep changes local, focused, and consistent with existing UI/API patterns. - Keep changes local, focused, and consistent with existing UI/API patterns.
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
- Remove obsolete code when re-implementing — never leave dead code behind. - Remove obsolete code when re-implementing — never leave dead code behind.
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone. - **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
(github.event_name == 'pull_request' && github.event.pull_request.merged == true) (github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps: steps:
- name: Move project item to Done - name: Move project item to Done
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: | script: |
+4 -1
View File
@@ -1,5 +1,8 @@
{ {
"vitest.root": "backend", "vitest.root": "backend",
"vitest.enable": true, "vitest.enable": true,
"vitest.commandLine": "npm test --" "vitest.commandLine": "npm test --",
"chat.tools.terminal.autoApprove": {
"test": true
}
} }
+49
View File
@@ -0,0 +1,49 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "E2E stable",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:with-video"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers + merged video",
"type": "shell",
"command": "npm",
"args": ["run", "test:e2e:all:with-video"],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
}
]
}
+8 -2
View File
@@ -18,8 +18,8 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" /> <img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" /> <img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p> </p>
### 🤖 AI-Generated Code ### 🤖 AI-Generated Code
@@ -123,6 +123,7 @@ Share your medication schedule with others via a public link.
- Track exact stock: packs, blisters, bottles, and loose pills - Track exact stock: packs, blisters, bottles, and loose pills
- Display remaining days of supply - Display remaining days of supply
- Automatic calculation based on intake schedule - Automatic calculation based on intake schedule
- Manual stock correction supports partial blisters and loose pills
### Medication Refill ### Medication Refill
- One-click refill with pack or loose pill options - One-click refill with pack or loose pill options
@@ -132,6 +133,7 @@ Share your medication schedule with others via a public link.
### Flexible Schedules ### Flexible Schedules
- Daily, weekly, or custom intervals per medication - Daily, weekly, or custom intervals per medication
- Independent schedules for each medication - Independent schedules for each medication
- Optional timeline filters for dashboard and shared schedule views
### Stock Alerts & Reminders ### Stock Alerts & Reminders
- Notifications before stock runs out - Notifications before stock runs out
@@ -143,6 +145,10 @@ Share your medication schedule with others via a public link.
- Plan ahead for vacations, business trips, or hospital stays - Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification - Send demand reports via email or push notification
### Reports
- Generate medication reports as PDF, Markdown, or plain text
- Include intake history, refill history, and prescription details
### Multi-Person Support ### Multi-Person Support
- Manage medications for multiple people - Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live - Share schedules via link. Recipients can mark doses as taken, you see it live
+1
View File
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+49 -1419
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.11.1", "version": "1.14.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -35,9 +35,9 @@
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.15", "@biomejs/biome": "^2.4.1",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"@types/nodemailer": "^6.4.21", "@types/nodemailer": "^7.0.10",
"@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",
+2 -4
View File
@@ -1,5 +1,4 @@
import { existsSync, statSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { type Client, createClient } from "@libsql/client"; import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql"; import { drizzle } from "drizzle-orm/libsql";
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
import { import {
ensureDataDirectory, ensureDataDirectory,
ensureDefaultUser, ensureDefaultUser,
getDataDir,
getDbPaths, getDbPaths,
repairOrphanedDoseIds, repairOrphanedDoseIds,
repairTrailingHyphenDoseIds, repairTrailingHyphenDoseIds,
@@ -65,8 +63,8 @@ let client: Client;
try { try {
client = createClient({ url }); client = createClient({ url });
log.debug(`[DB] Database client created successfully`); log.debug(`[DB] Database client created successfully`);
} catch (err: any) { } catch (err: unknown) {
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`); log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
log.error(`[DB] Database path: ${dbPath}`); log.error(`[DB] Database path: ${dbPath}`);
process.exit(1); process.exit(1);
} }
+29 -23
View File
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
writeFileSync(testFile, "test"); writeFileSync(testFile, "test");
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: unknown) {
return { success: false, error: err.message }; return { success: false, error: (err as Error).message };
} }
} }
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
try { try {
await migrate(database, { migrationsFolder }); await migrate(database, { migrationsFolder });
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: unknown) {
// If the error is about existing schema objects, the DB is already up-to-date // If the error is about existing schema objects, the DB is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns, // This happens when ALTER migrations in client.ts have already added the columns,
// or when tables were created before drizzle migrations were introduced // or when tables were created before drizzle migrations were introduced
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) { if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
return { success: true, warning: `Schema already up-to-date: ${err.message}` }; return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
} }
return { success: false, error: err.message }; return { success: false, error: (err as Error).message };
} }
} }
@@ -111,6 +111,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock // Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added for intake automation auditability (manual vs automatic taken)
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
// Added in v1.3.x - stock calculation mode (automatic/manual) // Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`, `ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets // Added for stock correction - hidden offset that doesn't affect looseTablets
@@ -140,6 +142,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`, `ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Added for share stock visibility toggle // Added for share stock visibility toggle
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`, `ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
// Added for timeline visibility toggles (dashboard + shared schedule)
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
// Added for prescription refill tracking and reminders // Added for prescription refill tracking and reminders
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`, `ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`, `ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
@@ -158,10 +164,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of alterMigrations) { for (const sql of alterMigrations) {
try { try {
await client.execute(sql); await client.execute(sql);
} catch (e: any) { } catch (e: unknown) {
// Silently ignore "duplicate column" errors - column already exists // Silently ignore "duplicate column" errors - column already exists
if (!e.message?.includes("duplicate column")) { if (!(e as Error).message?.includes("duplicate column")) {
errors.push(e.message); errors.push((e as Error).message);
} }
} }
} }
@@ -182,10 +188,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of createTableMigrations) { for (const sql of createTableMigrations) {
try { try {
await client.execute(sql); await client.execute(sql);
} catch (e: any) { } catch (e: unknown) {
// Silently ignore "table already exists" errors // Silently ignore "table already exists" errors
if (!e.message?.includes("already exists")) { if (!(e as Error).message?.includes("already exists")) {
errors.push(e.message); errors.push((e as Error).message);
} }
} }
} }
@@ -199,10 +205,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of createIndexMigrations) { for (const sql of createIndexMigrations) {
try { try {
await client.execute(sql); await client.execute(sql);
} catch (e: any) { } catch (e: unknown) {
// Silently ignore "already exists" errors // Silently ignore "already exists" errors
if (!e.message?.includes("already exists")) { if (!(e as Error).message?.includes("already exists")) {
errors.push(e.message); errors.push((e as Error).message);
} }
} }
} }
@@ -227,8 +233,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
return true; // Created return true; // Created
} }
return false; // Already exists return false; // Already exists
} catch (e: any) { } catch (e: unknown) {
console.error(`[DB] Error creating default user:`, e.message); console.error(`[DB] Error creating default user:`, (e as Error).message);
return false; return false;
} }
} }
@@ -255,8 +261,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'" "UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
); );
repaired = result.rowsAffected; repaired = result.rowsAffected;
} catch (e: any) { } catch (e: unknown) {
errors.push(`Trailing-hyphen repair failed: ${e.message}`); errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
} }
return { repaired, errors }; return { repaired, errors };
@@ -379,14 +385,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
args: [newDoseId, dose.id], args: [newDoseId, dose.id],
}); });
repaired++; repaired++;
} catch (e: any) { } catch (e: unknown) {
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`); errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
} }
} }
} }
} }
} catch (e: any) { } catch (e: unknown) {
errors.push(`Repair failed: ${e.message}`); errors.push(`Repair failed: ${(e as Error).message}`);
} }
return { repaired, errors }; return { repaired, errors };
+2 -2
View File
@@ -41,8 +41,8 @@ export async function executeMigration(
const executed = Number(tables.rows[0].count) || 0; const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors }; return { success: true, executed, errors };
} catch (err: any) { } catch (err: unknown) {
errors.push(err.message); errors.push((err as Error).message);
return { success: false, executed: 0, errors }; return { success: false, executed: 0, errors };
} }
} }
+12
View File
@@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] {
expiry_warning_days integer NOT NULL DEFAULT 90, expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
last_auto_email_sent text, last_auto_email_sent text,
last_notification_type text, last_notification_type text,
last_notification_channel text, last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
last_prescription_reminder_sent text,
last_prescription_reminder_channel text,
last_prescription_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')), updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`, )`,
+5
View File
@@ -100,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true), shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// UI timeline visibility preferences
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
// Last notification tracking (intake reminders) // Last notification tracking (intake reminders)
lastAutoEmailSent: text("last_auto_email_sent"), lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"), lastNotificationType: text("last_notification_type"),
@@ -159,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000" doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`), takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
}); });
+3
View File
@@ -20,6 +20,7 @@ import { medicationRoutes } from "./routes/medications.js";
import { oidcRoutes } from "./routes/oidc.js"; import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js"; import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js"; import { refillRoutes } from "./routes/refills.js";
import { reportRoutes } from "./routes/report.js";
import { settingsRoutes } from "./routes/settings.js"; import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js"; import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
@@ -118,6 +119,7 @@ export async function createApp(options?: {
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes); await app.register(exportRoutes);
await app.register(refillRoutes); await app.register(refillRoutes);
await app.register(reportRoutes);
return app; return app;
} }
@@ -190,6 +192,7 @@ await app.register(shareRoutes);
await app.register(doseRoutes); await app.register(doseRoutes);
await app.register(exportRoutes); await app.register(exportRoutes);
await app.register(refillRoutes); await app.register(refillRoutes);
await app.register(reportRoutes);
const start = async () => { const start = async () => {
try { try {
+5 -2
View File
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
id: user.id, id: user.id,
username: user.username, username: user.username,
}; };
} catch (err: any) { } catch (err: unknown) {
// Re-throw our own errors // Re-throw our own errors
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") { if (
err instanceof Error &&
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
) {
throw err; throw err;
} }
// JWT verification failed // JWT verification failed
+4
View File
@@ -56,6 +56,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 +95,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 };
@@ -227,6 +229,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,
})), })),
}; };
@@ -270,6 +273,7 @@ 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",
}); });
return { success: true }; return { success: true };
+85 -6
View File
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { extname, resolve } from "node:path"; import { extname, resolve } from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js"; import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js"; import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// ============================================================================= // =============================================================================
// Export Format Version (bump this when format changes) // Export Format Version (bump this when format changes)
// ============================================================================= // =============================================================================
const EXPORT_VERSION = "1.0"; const EXPORT_VERSION = "1.1";
// ============================================================================= // =============================================================================
// Zod Schemas for Import Validation // Zod Schemas for Import Validation
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
packCount: z.number().int().min(0).default(1), packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1), blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1), pillsPerBlister: z.number().int().min(1).default(1),
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0), looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"), packageType: z.enum(["blister", "bottle"]).default("blister"),
@@ -60,6 +61,7 @@ const medicationExportSchema = z.object({
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(), prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
prescriptionLowRefillThreshold: z.number().int().min(0).default(1), prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
prescriptionExpiryDate: z.string().nullable().optional(), prescriptionExpiryDate: z.string().nullable().optional(),
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
image: z.string().nullable().optional(), // base64 data URL or null image: z.string().nullable().optional(), // base64 data URL or null
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
}); });
@@ -70,10 +72,19 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(), markedBy: z.string().nullable().optional(),
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false), dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel") takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
}); });
const refillHistoryExportSchema = z.object({
medicationRef: z.string(), // References _exportId
packsAdded: z.number().int().min(0).default(0),
loosePillsAdded: z.number().int().min(0).default(0),
usedPrescription: z.boolean().default(false),
refillDate: z.string(), // ISO datetime
});
const shareLinkSchema = z.object({ const shareLinkSchema = z.object({
takenBy: z.string().min(1), takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30), scheduleDays: z.number().int().min(1).default(30),
@@ -106,9 +117,11 @@ const settingsExportSchema = z
lowStockDays: z.number().int().default(30), lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90), normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180), highStockDays: z.number().int().default(180),
expiryWarningDays: z.number().int().default(90),
// UI preferences // UI preferences
language: z.string().default("en"), language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"), stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareStockStatus: z.boolean().default(true),
}) })
.optional(); .optional();
@@ -118,6 +131,7 @@ const importDataSchema = z.object({
includeSensitiveData: z.boolean().default(false), includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]), medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]), doseHistory: z.array(doseHistorySchema).default([]),
refillHistory: z.array(refillHistoryExportSchema).default([]),
settings: settingsExportSchema, settings: settingsExportSchema,
shareLinks: z.array(shareLinkSchema).default([]), shareLinks: z.array(shareLinkSchema).default([]),
}); });
@@ -127,7 +141,7 @@ const importDataSchema = z.object({
// ============================================================================= // =============================================================================
// Helper to get user ID from request // Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> { async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) { if (!env.AUTH_ENABLED) {
return getAnonymousUserId(); return getAnonymousUserId();
} }
@@ -285,6 +299,7 @@ export async function exportRoutes(app: FastifyInstance) {
packCount: med.packCount ?? 1, packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1, blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1, pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0, looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0, stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister", packageType: med.packageType ?? "blister",
@@ -303,6 +318,7 @@ export async function exportRoutes(app: FastifyInstance) {
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null, prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null, prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null, image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso, lastStockCorrectionAt: lastStockCorrectionAtIso,
}; };
@@ -349,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
scheduledTime: scheduledTimeIso, scheduledTime: scheduledTimeIso,
takenAt: takenAtIso, takenAt: takenAtIso,
markedBy: dose.markedBy, markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false, dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person, takenByPerson: parsed.person,
}; };
@@ -380,8 +397,10 @@ export async function exportRoutes(app: FastifyInstance) {
lowStockDays: settings.lowStockDays, lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays, normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays, highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language, language: settings.language,
stockCalculationMode: settings.stockCalculationMode, stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
} }
: undefined; : undefined;
@@ -412,6 +431,39 @@ export async function exportRoutes(app: FastifyInstance) {
}; };
}); });
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Build export object // Build export object
const exportData = { const exportData = {
version: EXPORT_VERSION, version: EXPORT_VERSION,
@@ -419,12 +471,17 @@ export async function exportRoutes(app: FastifyInstance) {
includeSensitiveData: includeSensitive, includeSensitiveData: includeSensitive,
medications: exportMedications, medications: exportMedications,
doseHistory: exportDoseHistory, doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings, settings: exportSettings,
shareLinks: exportShareLinks, shareLinks: exportShareLinks,
}; };
// Set download headers // Set download headers
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`; const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json"); reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`); reply.header("Content-Disposition", `attachment; filename="${filename}"`);
@@ -475,7 +532,8 @@ export async function exportRoutes(app: FastifyInstance) {
} }
} }
// Delete in order: doses, share tokens, medications, settings // Delete in order: refill history, doses, share tokens, medications, settings
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
await db.delete(doseTracking).where(eq(doseTracking.userId, userId)); await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId)); await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId)); await db.delete(medications).where(eq(medications.userId, userId));
@@ -517,6 +575,7 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.inventory.blistersPerPack, blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister, pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets, looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0, stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null, lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null, pillWeightMg: med.pillWeightMg || null,
@@ -536,6 +595,7 @@ export async function exportRoutes(app: FastifyInstance) {
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null, prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1, prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null, prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null, // Will be set after image is saved imageUrl: null, // Will be set after image is saved
}) })
.returning(); .returning();
@@ -567,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
doseId, doseId,
takenAt: new Date(dose.takenAt), takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null, markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false, dismissed: dose.dismissed ?? false,
}); });
} }
@@ -594,8 +655,10 @@ export async function exportRoutes(app: FastifyInstance) {
lowStockDays: importData.settings.lowStockDays ?? 30, lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90, normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180, highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en", language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic", stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
}); });
} }
@@ -613,11 +676,27 @@ export async function exportRoutes(app: FastifyInstance) {
}); });
} }
// 7. Import refill history with remapped medication IDs
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue; // Skip orphaned refill records
await db.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
}
return { return {
success: true, success: true,
imported: { imported: {
medications: importData.medications.length, medications: importData.medications.length,
doseHistory: importData.doseHistory.length, doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
settings: importData.settings ? 1 : 0, settings: importData.settings ? 1 : 0,
shareLinks: importData.shareLinks.length, shareLinks: importData.shareLinks.length,
}, },
+128 -59
View File
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js"; import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js"; import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
@@ -623,9 +623,9 @@ export async function medicationRoutes(app: FastifyInstance) {
}; };
}); });
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets // Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
// Also sets lastStockCorrectionAt so consumed doses before this point don't count // Also sets lastStockCorrectionAt so consumed doses before this point don't count
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>( app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
"/medications/:id/stock-adjustment", "/medications/:id/stock-adjustment",
async (req, reply) => { async (req, reply) => {
const idNum = Number(req.params.id); const idNum = Number(req.params.id);
@@ -640,16 +640,32 @@ export async function medicationRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, idNum), eq(medications.userId, userId))); .where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound(); if (!existing) return reply.notFound();
const { stockAdjustment } = req.body as { stockAdjustment: number }; const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number"); if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
if (
looseTablets !== undefined &&
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
) {
return reply.badRequest("looseTablets must be a non-negative integer");
}
const updateFields: {
stockAdjustment: number;
lastStockCorrectionAt: Date;
updatedAt: Date;
looseTablets?: number;
} = {
stockAdjustment,
lastStockCorrectionAt: new Date(),
updatedAt: new Date(),
};
if (looseTablets !== undefined) {
updateFields.looseTablets = looseTablets;
}
const result = await db const result = await db
.update(medications) .update(medications)
.set({ .set(updateFields)
stockAdjustment,
lastStockCorrectionAt: new Date(), // Mark when correction was made
updatedAt: new Date(),
})
.where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning(); .returning();
@@ -776,26 +792,38 @@ export async function medicationRoutes(app: FastifyInstance) {
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.orderBy(medications.id); .orderBy(medications.id);
const [settingsRow] = await db
.select({ stockCalculationMode: userSettings.stockCalculationMode })
.from(userSettings)
.where(eq(userSettings.userId, userId));
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
// Get all taken doses for this user to calculate actual consumption // Get all taken doses for this user to calculate actual consumption
const takenDoses = await db const takenDoses = await db
.select() .select()
.from(doseTracking) .from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
// Create a map of medication ID to taken dose count const takenDoseIdsByMed = new Map<number, Set<string>>();
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>(); const takenDoseTimestamps = new Map<string, number>();
takenDoses.forEach((dose) => { takenDoses.forEach((dose) => {
const parts = dose.doseId.split("-"); const parts = dose.doseId.split("-");
if (parts.length >= 3) { if (parts.length < 3) return;
const medId = parseInt(parts[0], 10); const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10); if (Number.isNaN(medId)) return;
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
if (!takenDosesMap.has(medId)) { if (!takenDoseIdsByMed.has(medId)) {
takenDosesMap.set(medId, []); takenDoseIdsByMed.set(medId, new Set());
}
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
} }
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
const rawTakenAt = Number(dose.takenAt);
let takenAtMs: number;
if (Number.isFinite(rawTakenAt)) {
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
} else {
takenAtMs = new Date(dose.takenAt).getTime();
} }
takenDoseTimestamps.set(dose.doseId, takenAtMs);
}); });
// Use current time as the reference point for "available" stock // Use current time as the reference point for "available" stock
@@ -822,69 +850,109 @@ export async function medicationRoutes(app: FastifyInstance) {
? looseTablets + stockAdjustment ? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment; : packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption based on ACTUAL taken doses from dose_tracking // Calculate consumption with the same automatic/manual behavior as frontend coverage.
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
// Use the same logic as frontend: generate expected doses and check which are marked
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0; const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
// Build a Set of taken dose IDs for quick lookup
const takenDoseIds = new Set(
takenDoses
.filter((dose) => {
const parts = dose.doseId.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
})
.map((dose) => dose.doseId)
);
// Count consumed pills by generating expected doses and checking if they're taken // Count consumed pills by generating expected doses and checking if they're taken
let consumedUntilNow = 0; let consumedUntilNow = 0;
const msPerDay = 86400000; const msPerDay = 86400000;
if (stockCalculationMode === "automatic") {
blisters.forEach((blister, blisterIdx) => { blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start); const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart.getTime())) return; if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay; const period = Math.max(1, blister.every) * msPerDay;
// After a stock correction, start counting from the NEXT scheduled
// dose, because the user's pill count already reflects all
// consumption up to the correction time.
let effectiveStart: number; let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) { if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
effectiveStart = stockCorrectionCutoff + period; const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else { } else {
effectiveStart = blisterStart.getTime(); effectiveStart = blisterStart;
} }
if (effectiveStart > now.getTime()) return;
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
// Get the people for this intake (from intakes array or medication takenBy)
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
const intake = intakes[blisterIdx]; const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy; const intakePerson = intake?.takenBy;
const peopleForThisIntake: (string | null)[] = intakePerson const fallbackPeople = parseTakenByJson(row.takenByJson);
? [intakePerson] let peopleForThisIntake: Array<string | null>;
: takenByJson.length > 0 if (intakePerson) {
? takenByJson peopleForThisIntake = [intakePerson];
: [null]; } else if (fallbackPeople.length > 0) {
peopleForThisIntake = fallbackPeople;
} else {
peopleForThisIntake = [null];
}
// Generate expected dose IDs and check if they're taken let timeBasedConsumed = 0;
for (let i = 0; i < occurrences; i++) { let lastAutoConsumedDateMs = 0;
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
// Check if each person has taken this dose if (effectiveStart <= now.getTime()) {
for (const person of peopleForThisIntake) { const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId; timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
if (takenDoseIds.has(doseId)) {
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += blister.usage;
}
}
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
});
} else {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start);
const blisterStartDateOnly = new Date(
blisterStart.getFullYear(),
blisterStart.getMonth(),
blisterStart.getDate()
).getTime();
if (Number.isNaN(blisterStartDateOnly)) return;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const parsedBlisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
continue;
}
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
consumedUntilNow += blister.usage; consumedUntilNow += blister.usage;
} }
} }
}
}); });
}
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow); const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
@@ -930,6 +998,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationId: row.id, medicationId: row.id,
medicationName: row.name, medicationName: row.name,
totalPills: currentStock, totalPills: currentStock,
currentPills: currentStock,
plannerUsage: usageTotal, plannerUsage: usageTotal,
blisterSize: pillsPerBlister, blisterSize: pillsPerBlister,
blistersNeeded, blistersNeeded,
+6 -3
View File
@@ -104,7 +104,7 @@ export async function oidcRoutes(app: FastifyInstance) {
}); });
return reply.redirect(authUrl.href); return reply.redirect(authUrl.href);
} catch (err: any) { } catch (err: unknown) {
console.error("[OIDC] Login error:", err); console.error("[OIDC] Login error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
} }
@@ -167,7 +167,10 @@ export async function oidcRoutes(app: FastifyInstance) {
// Extract username from configured claim // Extract username from configured claim
const usernameClaim = env.OIDC_USERNAME_CLAIM; const usernameClaim = env.OIDC_USERNAME_CLAIM;
const username = const username =
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub; (userInfo as Record<string, string>)[usernameClaim] ||
userInfo.preferred_username ||
userInfo.email ||
userInfo.sub;
const oidcSubject = userInfo.sub; const oidcSubject = userInfo.sub;
if (!username || !oidcSubject) { if (!username || !oidcSubject) {
@@ -210,7 +213,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// In dev: CORS_ORIGINS contains the frontend URL // In dev: CORS_ORIGINS contains the frontend URL
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173"; const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
return reply.redirect(`${frontendUrl}/dashboard`); return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: any) { } catch (err: unknown) {
console.error("[OIDC] Callback error:", err); console.error("[OIDC] Callback error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`); return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
} }
+16 -11
View File
@@ -509,8 +509,10 @@ ${getFooterPlain(language)}`;
const buildTableRow = (row: LowStockItem) => { const buildTableRow = (row: LowStockItem) => {
const isEmpty = row.medsLeft <= 0; const isEmpty = row.medsLeft <= 0;
const isCritical = row.isCritical !== false; const isCritical = row.isCritical !== false;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const safeName = escapeHtml(row.name); const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0; const safeMedsLeft = Number(row.medsLeft) || 0;
const safeDaysLeft = Number(row.daysLeft) || 0; const safeDaysLeft = Number(row.daysLeft) || 0;
@@ -586,7 +588,7 @@ ${getFooterPlain(language)}`;
// Send push notification if enabled // Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
try { try {
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message); const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
@@ -603,7 +605,8 @@ ${getFooterPlain(language)}`;
// Update the reminder state to record this notification was sent // Update the reminder state to record this notification was sent
if (results.email || results.push) { if (results.email || results.push) {
const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; const singleChannel = results.email ? "email" : "push";
const channel = results.email && results.push ? "both" : singleChannel;
updateReminderSentTime("stock", channel); updateReminderSentTime("stock", channel);
// Also update user settings in database so frontend can display the info // Also update user settings in database so frontend can display the info
@@ -700,14 +703,15 @@ ${getFooterPlain(language)}`;
const bodyText = const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText = const emptyAlert =
emptyRx.length > 0 emptyRx.length === 1
? emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle ? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
: lowRx.length === 1 const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle ? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = filteredPrescriptionLow const tableRows = filteredPrescriptionLow
.map((item) => { .map((item) => {
@@ -807,7 +811,7 @@ ${getFooterPlain(language)}`;
); );
} }
} }
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
try { try {
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message); const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
@@ -823,7 +827,8 @@ ${getFooterPlain(language)}`;
} }
if (results.email || results.push) { if (results.email || results.push) {
const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; const singleChannel = results.email ? "email" : "push";
const channel = results.email && results.push ? "both" : singleChannel;
updateReminderSentTime("prescription", channel); updateReminderSentTime("prescription", channel);
await updateUserReminderSentTime(userId, "prescription", channel, medNames); await updateUserReminderSentTime(userId, "prescription", channel, medNames);
} }
+26 -13
View File
@@ -52,23 +52,37 @@ export async function refillRoutes(app: FastifyInstance) {
if (!med) return reply.notFound("Medication not found"); if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data; const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const isBottle = (med.packageType ?? "blister") === "bottle";
const effectivePacksAdded = isBottle ? 0 : packsAdded;
const effectiveLoosePillsAdded = loosePillsAdded;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
if (usePrescription) { if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) { if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" }); return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
} }
const remaining = med.prescriptionRemainingRefills ?? 0; if (remainingPrescriptionRefills <= 0) {
if (remaining <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" }); return reply.status(409).send({ error: "No remaining prescription refills" });
} }
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
} }
// Update medication stock // Update medication stock
const newPackCount = med.packCount + packsAdded; const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded; const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription const newRemainingRefills = usePrescription
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1) ? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null); : (med.prescriptionRemainingRefills ?? null);
await db await db
@@ -77,8 +91,6 @@ export async function refillRoutes(app: FastifyInstance) {
packCount: newPackCount, packCount: newPackCount,
looseTablets: newLooseTablets, looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills, prescriptionRemainingRefills: newRemainingRefills,
stockAdjustment: 0, // Reset offset since we're adding to base stock
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(and(eq(medications.id, medId), eq(medications.userId, userId))); .where(and(eq(medications.id, medId), eq(medications.userId, userId)));
@@ -89,16 +101,17 @@ export async function refillRoutes(app: FastifyInstance) {
.values({ .values({
medicationId: medId, medicationId: medId,
userId, userId,
packsAdded, packsAdded: effectivePacksAdded,
loosePillsAdded, loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription, usedPrescription: usePrescription,
}) })
.returning(); .returning();
// Calculate pills added for response (packageType-aware) // Calculate pills added for response (packageType-aware)
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded; const totalPillsAdded = isBottle
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
const newTotalPills = isBottle const newTotalPills = isBottle
? newLooseTablets + (med.stockAdjustment ?? 0) ? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0); : newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
@@ -107,8 +120,8 @@ export async function refillRoutes(app: FastifyInstance) {
success: true, success: true,
refill: { refill: {
id: refill.id, id: refill.id,
packsAdded, packsAdded: effectivePacksAdded,
loosePillsAdded, loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded, totalPillsAdded,
refillDate: refill.refillDate, refillDate: refill.refillDate,
}, },
+113
View File
@@ -0,0 +1,113 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, refillHistory } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
});
export async function reportRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// POST /medications/report-data - Get aggregated dose/refill data for report generation
app.post("/medications/report-data", async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds } = parsed.data;
// Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
}
}
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
}
> = {};
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
// Get refills for this medication
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
};
}
return result;
});
}
+24 -3
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js"; import { userSettings } from "../db/schema.js";
@@ -33,6 +33,9 @@ export type UserSettings = {
language: Language; language: Language;
stockCalculationMode: "automatic" | "manual"; stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean; shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
lastAutoEmailSent: string | null; lastAutoEmailSent: string | null;
lastNotificationType: string | null; lastNotificationType: string | null;
lastNotificationChannel: string | null; lastNotificationChannel: string | null;
@@ -69,6 +72,9 @@ type SettingsBody = {
language: string; language: string;
stockCalculationMode: "automatic" | "manual"; stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean; shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
}; };
type TestEmailBody = { type TestEmailBody = {
@@ -119,6 +125,9 @@ function getDefaultSettings() {
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en", language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic", stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true), shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
swapDashboardMainSections: false,
lastAutoEmailSent: null, lastAutoEmailSent: null,
lastNotificationType: null, lastNotificationType: null,
lastNotificationChannel: null, lastNotificationChannel: null,
@@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
language: settings.language as Language, language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true, shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent, lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType, lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel, lastNotificationChannel: settings.lastNotificationChannel,
@@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
language: settings.language as Language, language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true, shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent, lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType, lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel, lastNotificationChannel: settings.lastNotificationChannel,
@@ -239,7 +254,7 @@ export async function settingsRoutes(app: FastifyInstance) {
// Helper to get user ID from request // Helper to get user ID from request
// Returns anonymous user ID when auth is disabled // Returns anonymous user ID when auth is disabled
async function getUserId(request: any, reply: any): Promise<number> { async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
// If auth is disabled, use the anonymous user // If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) { if (!env.AUTH_ENABLED) {
return getAnonymousUserId(); return getAnonymousUserId();
@@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) {
language: settings.language, language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic", stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true, shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
// SMTP settings (from .env - shared/server-configured) // SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "", smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10), smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
@@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) {
language: body.language ?? "en", language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic", stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true, shareStockStatus: body.shareStockStatus ?? true,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -544,7 +565,7 @@ export async function sendShoutrrrNotification(
} }
// Use ONLY the reconstructed URL from validation - never the original urlStr // Use ONLY the reconstructed URL from validation - never the original urlStr
const { url: sanitizedUrl, isNtfy, auth } = validation; const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
let targetUrl: string; let targetUrl: string;
const method = "POST"; const method = "POST";
+2
View File
@@ -154,6 +154,8 @@ export async function shareRoutes(app: FastifyInstance) {
}, },
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic", stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true, shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
}; };
}); });
+121 -11
View File
@@ -22,7 +22,6 @@ import {
getTimezone, getTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type Intake,
type IntakeReminderState, type IntakeReminderState,
parseIntakeReminderState, parseIntakeReminderState,
parseIntakesJson, parseIntakesJson,
@@ -51,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
} }
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
if (intake.takenBy) {
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
}
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
locale: string,
tz: string,
logger: ServiceLogger
): Promise<number> {
if (settings.stockCalculationMode !== "automatic") {
return 0;
}
const now = new Date();
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
const existingToday = await db
.select({ doseId: doseTracking.doseId })
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, settings.userId),
gte(doseTracking.takenAt, todayStart),
lte(doseTracking.takenAt, todayEnd)
)
);
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
let inserted = 0;
for (const med of rows) {
if (med.isObsolete) {
continue;
}
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
if (intakes.length === 0) {
continue;
}
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const todaysIntakes = getTodaysIntakes(
med.name,
intakes,
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
for (const intake of todaysIntakes) {
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
continue;
}
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
continue;
}
const doseId = buildDoseIdForIntake({
...intake,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
});
if (existingDoseIds.has(doseId)) {
continue;
}
await db.insert(doseTracking).values({
userId: settings.userId,
doseId,
takenAt: intake.intakeTime,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
existingDoseIds.add(doseId);
inserted++;
}
}
if (inserted > 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
}
return inserted;
}
async function sendIntakeReminderEmail( async function sendIntakeReminderEmail(
email: string, email: string,
intakes: UpcomingIntake[], intakes: UpcomingIntake[],
@@ -247,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}` `[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
); );
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const locale = getDateLocale(language);
const tz = getTimezone();
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
// Check if any intake reminder notifications are enabled (granular check) // Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders; const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
@@ -263,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
); );
// Get all medications with intake reminders enabled for this user // Get all medications with intake reminders enabled for this user
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled); const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) { if (medsWithReminders.length === 0) {
@@ -281,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
const state = loadIntakeReminderState(); const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
const locale = getDateLocale(language);
const tz = getTimezone();
// Get start and end of today in user's timezone (for filtering today's doses only) // Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date(); const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
@@ -321,7 +430,7 @@ async function checkAndSendIntakeRemindersForUser(
}); });
// Process each intake separately to track blisterIndex // Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, blisterIndex) => { intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.debug( logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}` `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
@@ -684,7 +793,8 @@ async function checkAndSendIntakeRemindersForUser(
saveIntakeReminderState(state); saveIntakeReminderState(state);
// Update global reminder state for UI display // Update global reminder state for UI display
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
updateReminderSentTime("intake", channel); updateReminderSentTime("intake", channel);
// Also update user settings in database so frontend can display the info // Also update user settings in database so frontend can display the info
+271 -27
View File
@@ -1,10 +1,10 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js"; import { getDataDir } from "../db/db-utils.js";
import { medications, userSettings } from "../db/schema.js"; import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js"; import type { ServiceLogger } from "../utils/logger.js";
@@ -19,8 +19,10 @@ import {
getNextScheduledTime, getNextScheduledTime,
getTimezone, getTimezone,
getTodayInTimezone, getTodayInTimezone,
parseBlisters, parseIntakesJson,
parseLocalDateTime,
parseReminderState, parseReminderState,
parseTakenByJson,
type ReminderState, type ReminderState,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
@@ -38,6 +40,56 @@ function escapeHtml(text: string): string {
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
const LOCK_STALE_MS = 15 * 60 * 1000;
function ensureReminderLocksDir(): void {
if (!existsSync(reminderLocksDir)) {
mkdirSync(reminderLocksDir, { recursive: true });
}
}
function acquireReminderSendLock(lockKey: string): string | null {
ensureReminderLocksDir();
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
const tryCreateLock = (): boolean => {
try {
const fd = openSync(lockFilePath, "wx");
closeSync(fd);
return true;
} catch {
return false;
}
};
if (tryCreateLock()) {
return lockFilePath;
}
try {
const stats = statSync(lockFilePath);
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
unlinkSync(lockFilePath);
if (tryCreateLock()) {
return lockFilePath;
}
}
} catch {
// ignore; lock acquisition fails safely
}
return null;
}
function releaseReminderSendLock(lockFilePath: string | null): void {
if (!lockFilePath) return;
try {
unlinkSync(lockFilePath);
} catch {
// ignore release errors
}
}
function loadReminderState(): ReminderState { function loadReminderState(): ReminderState {
try { try {
@@ -119,10 +171,6 @@ export async function updateUserReminderSentTime(
} }
} }
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
return parseBlisters(row);
}
type LowStockItem = { type LowStockItem = {
name: string; name: string;
medsLeft: number; medsLeft: number;
@@ -142,7 +190,8 @@ async function getMedicationsNeedingReminder(
userId: number, userId: number,
reminderDaysBefore: number, reminderDaysBefore: number,
lowStockDays: number, lowStockDays: number,
language: Language language: Language,
stockCalculationMode: "automatic" | "manual"
): Promise<LowStockItem[]> { ): Promise<LowStockItem[]> {
const rows = await db const rows = await db
.select() .select()
@@ -150,15 +199,152 @@ async function getMedicationsNeedingReminder(
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false))) .where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.orderBy(medications.id); .orderBy(medications.id);
const takenDoseRows = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
const takenDoseIdsByMed = new Map<number, Set<string>>();
const takenDoseTimestamps = new Map<string, number>();
for (const dose of takenDoseRows) {
const parts = dose.doseId.split("-");
if (parts.length < 3) continue;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) continue;
if (!takenDoseIdsByMed.has(medId)) {
takenDoseIdsByMed.set(medId, new Set());
}
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
const rawTakenAt = Number(dose.takenAt);
let takenAtMs: number;
if (Number.isFinite(rawTakenAt)) {
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
} else {
takenAtMs = new Date(dose.takenAt).getTime();
}
takenDoseTimestamps.set(dose.doseId, takenAtMs);
}
const lowStock: LowStockItem[] = []; const lowStock: LowStockItem[] = [];
const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) { for (const row of rows) {
const blisters = parseBlistersFromRow(row); const intakes = parseIntakesJson(
const totalPills = row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const originalTotalPills =
(row.packageType ?? "blister") === "bottle" (row.packageType ?? "blister") === "bottle"
? row.looseTablets + (row.stockAdjustment ?? 0) ? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0); : row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
let consumed = 0;
if (stockCalculationMode === "automatic") {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const fallbackPeople = parseTakenByJson(row.takenByJson);
let peopleForThisIntake: Array<string | null>;
if (intakePerson) {
peopleForThisIntake = [intakePerson];
} else if (fallbackPeople.length > 0) {
peopleForThisIntake = fallbackPeople;
} else {
peopleForThisIntake = [null];
}
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += blister.usage;
}
}
consumed += timeBasedConsumed + earlyTakenConsumed;
});
} else {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start);
const blisterStartDateOnly = new Date(
blisterStart.getFullYear(),
blisterStart.getMonth(),
blisterStart.getDate()
).getTime();
if (Number.isNaN(blisterStartDateOnly)) return;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const parsedBlisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
continue;
}
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
consumed += blister.usage;
}
}
});
}
const currentPills = Math.max(0, originalTotalPills - consumed);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
if (daysLeft === null) continue; if (daysLeft === null) continue;
@@ -168,7 +354,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) { if (isCritical || isLow) {
lowStock.push({ lowStock.push({
name: row.name, name: row.name,
medsLeft: totalPills, medsLeft: currentPills,
daysLeft, daysLeft,
depletionDate, depletionDate,
isCritical, isCritical,
@@ -200,6 +386,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
})); }));
} }
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
export async function getMedicationsNeedingReminderForTests(
userId: number,
reminderDaysBefore: number,
lowStockDays: number,
language: Language,
stockCalculationMode: "automatic" | "manual"
): Promise<
Array<{
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical: boolean;
}>
> {
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
}
async function sendReminderEmail( async function sendReminderEmail(
email: string, email: string,
lowStock: LowStockItem[], lowStock: LowStockItem[],
@@ -275,8 +480,10 @@ async function sendReminderEmail(
.map((row) => { .map((row) => {
const isEmpty = row.medsLeft <= 0; const isEmpty = row.medsLeft <= 0;
const isCritical = row.isCritical; const isCritical = row.isCritical;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
return ` return `
<tr style="background: ${rowBg};"> <tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td> <td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
@@ -329,7 +536,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
--- ---
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s"; const pluralSuffix = language === "de" ? "e" : "s";
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural }); const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
try { try {
@@ -400,12 +608,18 @@ async function checkAndSendReminderForUser(
settings.userId, settings.userId,
settings.reminderDaysBefore, settings.reminderDaysBefore,
settings.lowStockDays, settings.lowStockDays,
language language,
settings.stockCalculationMode
); );
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId); const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) { if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) { if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
if (!stockSendLock) {
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
} else {
try {
logger.info( logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
); );
@@ -460,7 +674,7 @@ async function checkAndSendReminderForUser(
) )
); );
} }
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success; shoutrrrSuccess = result.success;
if (!result.success) { if (!result.success) {
@@ -470,7 +684,8 @@ async function checkAndSendReminderForUser(
if (emailSuccess || shoutrrrSuccess) { if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState(); const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({ saveReminderState({
lastAutoEmailSent: new Date().toISOString(), lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today, lastAutoEmailDate: today,
@@ -480,15 +695,25 @@ async function checkAndSendReminderForUser(
lastNotificationChannel: channel, lastNotificationChannel: channel,
}); });
const firstMed = allLowStock[0];
const medNames = allLowStock.map((m) => m.name).join(", "); const medNames = allLowStock.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
} }
} finally {
releaseReminderSendLock(stockSendLock);
}
}
} }
} }
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) { if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
if (!prescriptionSendLock) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
);
} else {
try {
logger.info( logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...` `[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
); );
@@ -536,15 +761,18 @@ async function checkAndSendReminderForUser(
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length }); : t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const bodyText = const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText =
emptyRx.length > 0 emptyRx.length > 0
? emptyRx.length === 1 ? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle ? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
: lowRx.length === 1 const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle ? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = allPrescriptionLow const tableRows = allPrescriptionLow
.map((item) => { .map((item) => {
@@ -649,7 +877,7 @@ async function checkAndSendReminderForUser(
); );
} }
} }
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success; shoutrrrSuccess = result.success;
if (!result.success) { if (!result.success) {
@@ -659,7 +887,8 @@ async function checkAndSendReminderForUser(
if (emailSuccess || shoutrrrSuccess) { if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState(); const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({ saveReminderState({
lastAutoEmailSent: new Date().toISOString(), lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today, lastAutoEmailDate: today,
@@ -669,15 +898,19 @@ async function checkAndSendReminderForUser(
lastNotificationChannel: channel, lastNotificationChannel: channel,
}); });
const firstMed = allPrescriptionLow[0];
const medNames = allPrescriptionLow.map((m) => m.name).join(", "); const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames); await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
} }
} finally {
releaseReminderSendLock(prescriptionSendLock);
}
}
} }
} }
} }
let schedulerTimeout: NodeJS.Timeout | null = null; let schedulerTimeout: NodeJS.Timeout | null = null;
let schedulerStarted = false;
function scheduleNextCheck(logger: ServiceLogger): void { function scheduleNextCheck(logger: ServiceLogger): void {
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR); const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
@@ -702,6 +935,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)
@@ -721,9 +959,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;
} }
+9 -9
View File
@@ -294,8 +294,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
// Should set cookies // Should set cookies
const cookies = response.cookies; const cookies = response.cookies;
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined(); expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined(); expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
}); });
it("should login case-insensitively with different username casing", async () => { it("should login case-insensitively with different username casing", async () => {
@@ -393,7 +393,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -456,7 +456,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -506,7 +506,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const accessToken = login.cookies.find((c: any) => c.name === "access_token"); const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
@@ -604,7 +604,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const accessToken = login.cookies.find((c: any) => c.name === "access_token"); const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({ const response = await app.inject({
method: "PUT", method: "PUT",
@@ -653,7 +653,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const accessToken = login.cookies.find((c: any) => c.name === "access_token"); const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({ const response = await app.inject({
method: "PUT", method: "PUT",
@@ -689,7 +689,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const accessToken = login.cookies.find((c: any) => c.name === "access_token"); const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({ const response = await app.inject({
method: "PUT", method: "PUT",
@@ -742,7 +742,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
}, },
}); });
const accessToken = login.cookies.find((c: any) => c.name === "access_token"); const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
// Delete account // Delete account
const response = await app.inject({ const response = await app.inject({
+1 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { createClient } from "@libsql/client"; import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql"; import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator"; import { migrate } from "drizzle-orm/libsql/migrator";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB) // Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
import { import {
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type ClientTestOptions = {
dirWritable?: boolean;
authEnabled?: boolean;
};
async function loadDbClientModule(options: ClientTestOptions = {}) {
const { dirWritable = true, authEnabled = false } = options;
vi.resetModules();
vi.restoreAllMocks();
process.env.AUTH_ENABLED = authEnabled ? "true" : "false";
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
const existsSync = vi.fn().mockReturnValue(false);
const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 });
vi.doMock("node:fs", () => ({ existsSync, statSync }));
const dotenvConfig = vi.fn();
vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } }));
const createClient = vi.fn().mockReturnValue({ execute: vi.fn() });
vi.doMock("@libsql/client", () => ({ createClient }));
const drizzle = vi.fn().mockReturnValue({ __db: true });
vi.doMock("drizzle-orm/libsql", () => ({ drizzle }));
const ensureDataDirectory = vi
.fn()
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
const getDbPaths = vi.fn().mockReturnValue({
dataDir: "/tmp/medassist-data",
dbPath: "/tmp/medassist-data/medassist.db",
url: "file:/tmp/medassist-data/medassist.db",
});
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
vi.doMock("../db/db-utils.js", () => ({
buildDbUrl: vi.fn(),
getDataDir: vi.fn(),
ensureDataDirectory,
getDbPaths,
runDrizzleMigrations,
runAlterMigrations,
repairTrailingHyphenDoseIds,
repairOrphanedDoseIds,
ensureDefaultUser,
}));
const log = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.doMock("../utils/logger.js", () => ({ log }));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
const modulePromise = import("../db/client.js");
return {
modulePromise,
mocks: {
existsSync,
statSync,
dotenvConfig,
createClient,
drizzle,
ensureDataDirectory,
getDbPaths,
runDrizzleMigrations,
runAlterMigrations,
repairTrailingHyphenDoseIds,
repairOrphanedDoseIds,
ensureDefaultUser,
log,
exitSpy,
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("db/client bootstrap", () => {
it("initializes db and runs migrations when directory is writable", async () => {
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false });
const mod = await modulePromise;
expect(mod.db).toBeTruthy();
expect(mod.migrationsReady).toBeInstanceOf(Promise);
await mod.migrationsReady;
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1);
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false);
});
it("passes auth-enabled flag to ensureDefaultUser", async () => {
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true });
const mod = await modulePromise;
await mod.migrationsReady;
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true);
});
it("exits when data directory is not writable", async () => {
const { modulePromise } = await loadDbClientModule({ dirWritable: false });
await expect(modulePromise).rejects.toThrow("process.exit:1");
});
});
+1 -1
View File
@@ -271,7 +271,7 @@ describe("Dose Tracking API", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const data = response.json(); const data = response.json();
expect(data.doses).toHaveLength(2); expect(data.doses).toHaveLength(2);
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort()); expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
// Each dose should have a takenAt timestamp // Each dose should have a takenAt timestamp
for (const dose of data.doses) { for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number"); expect(dose.takenAt).toBeTypeOf("number");
+199 -9
View File
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
const { settingsRoutes } = await import("../routes/settings.js"); const { settingsRoutes } = await import("../routes/settings.js");
const { healthRoutes } = await import("../routes/health.js"); const { healthRoutes } = await import("../routes/health.js");
const { refillRoutes } = await import("../routes/refills.js"); const { refillRoutes } = await import("../routes/refills.js");
const { reportRoutes } = await import("../routes/report.js");
const { exportRoutes } = await import("../routes/export.js"); const { exportRoutes } = await import("../routes/export.js");
// ============================================================================= // =============================================================================
@@ -137,6 +138,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1, share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
last_auto_email_sent text, last_auto_email_sent text,
last_notification_type text, last_notification_type text,
last_notification_channel text, last_notification_channel text,
@@ -167,6 +171,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL, dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')), taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text, marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0, dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`, )`,
@@ -261,11 +266,80 @@ describe("E2E Tests with Real Routes", () => {
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(refillRoutes); await app.register(refillRoutes);
await app.register(reportRoutes);
await app.register(exportRoutes); await app.register(exportRoutes);
await app.ready(); await app.ready();
}); });
// ---------------------------------------------------------------------------
// Report Routes
// ---------------------------------------------------------------------------
describe("Real /medications/report-data route", () => {
it("should return 400 for invalid payload", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [] },
});
expect(response.statusCode).toBe(400);
});
it("should return 403 when requested medication is not owned by user", async () => {
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [999999] },
});
expect(response.statusCode).toBe(403);
expect(response.json().error).toBe("Access denied to medication");
});
it("should aggregate taken/dismissed doses and refill history", async () => {
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
// One taken dose and one dismissed dose for the same medication
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-1735344000000`, 1735344000],
});
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 1)`,
args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, userId, 2, 5, 1, 1735516800],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId] },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data[medId].dosesTaken).toBe(1);
expect(data[medId].dosesDismissed).toBe(1);
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
expect(data[medId].refills).toHaveLength(1);
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 2,
loosePillsAdded: 5,
usedPrescription: true,
});
});
});
afterAll(async () => { afterAll(async () => {
await app.close(); await app.close();
testClient.close(); testClient.close();
@@ -744,6 +818,39 @@ describe("E2E Tests with Real Routes", () => {
const data = getResponse.json(); const data = getResponse.json();
expect(data.repeatDailyReminders).toBe(false); expect(data.repeatDailyReminders).toBe(false);
}); });
it("should reject invalid language in lightweight language endpoint", async () => {
const response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "fr" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
});
it("should create and update language via lightweight language endpoint", async () => {
let response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "de" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "en" },
});
expect(response.statusCode).toBe(200);
const getResponse = await app.inject({ method: "GET", url: "/settings" });
expect(getResponse.json().language).toBe("en");
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1671,7 +1778,7 @@ describe("E2E Tests with Real Routes", () => {
url: "/medications", url: "/medications",
}); });
expect(medsResponse.statusCode).toBe(200); expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: any) => m.id === medId); const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.prescriptionRemainingRefills).toBe(1); expect(med.prescriptionRemainingRefills).toBe(1);
const historyResponse = await app.inject({ const historyResponse = await app.inject({
@@ -1809,8 +1916,10 @@ describe("E2E Tests with Real Routes", () => {
const refills = response.json(); const refills = response.json();
expect(refills).toHaveLength(2); expect(refills).toHaveLength(2);
// Check both refills exist (order may vary) // Check both refills exist (order may vary)
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0); const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5); const hasLooseRefill = refills.some(
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
);
expect(hasPackRefill).toBe(true); expect(hasPackRefill).toBe(true);
expect(hasLooseRefill).toBe(true); expect(hasLooseRefill).toBe(true);
}); });
@@ -1888,7 +1997,7 @@ describe("E2E Tests with Real Routes", () => {
expect(getResponse.statusCode).toBe(200); expect(getResponse.statusCode).toBe(200);
const meds = getResponse.json(); const meds = getResponse.json();
const med = meds.find((m: any) => m.id === medId); const med = meds.find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeDefined(); expect(med).toBeDefined();
expect(med.stockAdjustment).toBe(-7); expect(med.stockAdjustment).toBe(-7);
expect(med.lastStockCorrectionAt).toBeTruthy(); expect(med.lastStockCorrectionAt).toBeTruthy();
@@ -1934,7 +2043,7 @@ describe("E2E Tests with Real Routes", () => {
method: "GET", method: "GET",
url: "/medications", url: "/medications",
}); });
const med = getResponse.json().find((m: any) => m.id === medId); const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.name).toBe("Renamed Med"); expect(med.name).toBe("Renamed Med");
expect(med.stockAdjustment).toBe(-5); expect(med.stockAdjustment).toBe(-5);
}); });
@@ -2003,7 +2112,7 @@ describe("E2E Tests with Real Routes", () => {
// Verify adjustment is set // Verify adjustment is set
let getMeds = await app.inject({ method: "GET", url: "/medications" }); let getMeds = await app.inject({ method: "GET", url: "/medications" });
let med = getMeds.json().find((m: any) => m.id === medId); let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.stockAdjustment).toBe(-10); expect(med.stockAdjustment).toBe(-10);
// Edit medication with CHANGED stock fields (packCount 1 → 2) // Edit medication with CHANGED stock fields (packCount 1 → 2)
@@ -2022,7 +2131,7 @@ describe("E2E Tests with Real Routes", () => {
// stockAdjustment should be reset to 0 // stockAdjustment should be reset to 0
getMeds = await app.inject({ method: "GET", url: "/medications" }); getMeds = await app.inject({ method: "GET", url: "/medications" });
med = getMeds.json().find((m: any) => m.id === medId); med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.stockAdjustment).toBe(0); expect(med.stockAdjustment).toBe(0);
expect(med.lastStockCorrectionAt).toBeTruthy(); expect(med.lastStockCorrectionAt).toBeTruthy();
}); });
@@ -2066,7 +2175,7 @@ describe("E2E Tests with Real Routes", () => {
// stockAdjustment should be preserved // stockAdjustment should be preserved
const getMeds = await app.inject({ method: "GET", url: "/medications" }); const getMeds = await app.inject({ method: "GET", url: "/medications" });
const med = getMeds.json().find((m: any) => m.id === medId); const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.name).toBe("Renamed Preserve Med"); expect(med.name).toBe("Renamed Preserve Med");
expect(med.stockAdjustment).toBe(-5); expect(med.stockAdjustment).toBe(-5);
}); });
@@ -2114,7 +2223,7 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const data = response.json(); const data = response.json();
const med = data.find((m: any) => m.medicationId === medId); const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
expect(med).toBeDefined(); expect(med).toBeDefined();
// Total should be very close to 113 (not 112 or lower from phantom consumption) // Total should be very close to 113 (not 112 or lower from phantom consumption)
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass) // Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
@@ -2201,6 +2310,87 @@ describe("E2E Tests with Real Routes", () => {
expect(data.settings).toBeDefined(); expect(data.settings).toBeDefined();
expect(data.settings.emailEnabled).toBe(true); expect(data.settings.emailEnabled).toBe(true);
}); });
it("should include sensitive settings when requested", async () => {
await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
shoutrrrEnabled: true,
shoutrrrUrl: "https://example.com/topic",
emailStockReminders: false,
emailIntakeReminders: false,
emailPrescriptionReminders: false,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
const response = await app.inject({
method: "GET",
url: "/export?includeSensitive=true",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.settings.shoutrrrEnabled).toBe(true);
expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic");
});
it("should gracefully export malformed date-like DB values", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Date Edge Med",
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const medId = createResponse.json().id as number;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-1735344000000`, "not-a-date"],
});
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [medId, userId, 1, 0, 0, "still-not-a-date"],
});
await testClient.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
args: [userId, "date-edge-token", "Daniel", 30, "broken-date"],
});
const response = await app.inject({ method: "GET", url: "/export" });
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doseHistory).toHaveLength(1);
expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false);
expect(data.refillHistory).toHaveLength(1);
expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false);
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].expiresAt).toBeNull();
});
}); });
describe("Real /import routes", () => { describe("Real /import routes", () => {
+76
View File
@@ -0,0 +1,76 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
describe("plugins/env runtime validation", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = {
...ORIGINAL_ENV,
DOTENV_PATH: "/tmp/medassist-nonexistent.env",
};
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it("loads with defaults when auth and oidc are disabled", async () => {
delete process.env.AUTH_ENABLED;
delete process.env.OIDC_ENABLED;
delete process.env.JWT_SECRET;
delete process.env.REFRESH_SECRET;
delete process.env.COOKIE_SECRET;
const mod = await import("../plugins/env.js");
expect(mod.env.AUTH_ENABLED).toBe(false);
expect(mod.env.OIDC_ENABLED).toBe(false);
expect(mod.env.PORT).toBe(3000);
});
it("exits when auth is enabled but secrets are missing", async () => {
process.env.AUTH_ENABLED = "true";
delete process.env.JWT_SECRET;
delete process.env.REFRESH_SECRET;
delete process.env.COOKIE_SECRET;
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
});
it("exits when oidc is enabled but required settings are missing", async () => {
process.env.AUTH_ENABLED = "false";
process.env.OIDC_ENABLED = "true";
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI;
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
});
it("loads when auth and oidc settings are complete", async () => {
process.env.AUTH_ENABLED = "true";
process.env.JWT_SECRET = "jwt-secret-for-runtime-test";
process.env.REFRESH_SECRET = "refresh-secret-runtime-test";
process.env.COOKIE_SECRET = "cookie-secret-runtime-test";
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER_URL = "https://auth.example.com";
process.env.OIDC_CLIENT_ID = "medassist";
process.env.OIDC_CLIENT_SECRET = "super-secret-client";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback";
const mod = await import("../plugins/env.js");
expect(mod.env.AUTH_ENABLED).toBe(true);
expect(mod.env.OIDC_ENABLED).toBe(true);
expect(mod.env.OIDC_CLIENT_ID).toBe("medassist");
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
// Mock process.exit to prevent tests from exiting // Mock process.exit to prevent tests from exiting
const mockExit = vi.fn(); const mockExit = vi.fn();
vi.spyOn(process, "exit").mockImplementation(mockExit as any); vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
// Re-create the schema from env.ts for testing // Re-create the schema from env.ts for testing
const EnvSchema = z.object({ const EnvSchema = z.object({
+18 -9
View File
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
const userId = 1; // Test user ID const userId = 1; // Test user ID
// Helper to parse blisters from DB // Helper to parse blisters from DB
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> { function parseBlisters(
const usage = JSON.parse(row.usage_json || "[]") as number[]; row: Record<string, unknown>
const every = JSON.parse(row.every_json || "[]") as number[]; ): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const start = JSON.parse(row.start_json || "[]") as string[]; const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
const every = JSON.parse((row.every_json as string) || "[]") as number[];
const start = JSON.parse((row.start_json as string) || "[]") as string[];
const len = Math.min(usage.length, every.length, start.length); const len = Math.min(usage.length, every.length, start.length);
return Array.from({ length: len }, (_, i) => ({ return Array.from({ length: len }, (_, i) => ({
usage: usage[i], usage: usage[i],
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
args: [userId], args: [userId],
}); });
let settings; let settings: Record<string, unknown> | undefined;
if (settingsResult.rows.length > 0) { if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0]; const s = settingsResult.rows[0];
settings = { settings = {
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
}); });
// POST /import // POST /import
app.post<{ Body: any }>("/import", async (request, reply) => { app.post("/import", async (request, reply) => {
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
const importData = request.body as any; const importData = request.body as any;
// Basic validation // Basic validation
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
// Import medications // Import medications
const exportIdToNewId = new Map<string, number>(); const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications || []) { for (const med of importData.medications || []) {
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage)); const usageJson = JSON.stringify(
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every)); ((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start)); );
const everyJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
);
const startJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
);
const takenByJson = JSON.stringify(med.takenBy || []); const takenByJson = JSON.stringify(med.takenBy || []);
const result = await client.execute({ const result = await client.execute({
+8 -4
View File
@@ -132,6 +132,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1, share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
last_auto_email_sent text, last_auto_email_sent text,
last_notification_type text, last_notification_type text,
last_notification_channel text, last_notification_channel text,
@@ -162,6 +165,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL, dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')), taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text, marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0, dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`, )`,
@@ -1333,8 +1337,8 @@ describe("Integration Tests", () => {
url: "/medications", url: "/medications",
}); });
const meds = medsRes.json(); const meds = medsRes.json();
const med1 = meds.find((m: any) => m.id === med1Id); const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
const med2 = meds.find((m: any) => m.id === med2Id); const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
expect(med1.dismissedUntil).toBe("2025-01-15"); expect(med1.dismissedUntil).toBe("2025-01-15");
expect(med2.dismissedUntil).toBe("2025-01-15"); expect(med2.dismissedUntil).toBe("2025-01-15");
@@ -1376,7 +1380,7 @@ describe("Integration Tests", () => {
method: "GET", method: "GET",
url: "/medications", url: "/medications",
}); });
const med = medsRes.json().find((m: any) => m.id === medId); const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.dismissedUntil).toBeNull(); expect(med.dismissedUntil).toBeNull();
}); });
@@ -1446,7 +1450,7 @@ describe("Integration Tests", () => {
method: "GET", method: "GET",
url: "/medications", url: "/medications",
}); });
const med = medsRes.json().find((m: any) => m.id === medId); const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med.dismissedUntil).toBeNull(); expect(med.dismissedUntil).toBeNull();
}); });
}); });
+151
View File
@@ -0,0 +1,151 @@
import cookie from "@fastify/cookie";
import Fastify from "fastify";
import { afterEach, describe, expect, it, vi } from "vitest";
type OidcMocks = {
discovery: ReturnType<typeof vi.fn>;
buildAuthorizationUrl: ReturnType<typeof vi.fn>;
};
async function buildOidcApp(envOverrides: Record<string, unknown>) {
vi.resetModules();
const env = {
OIDC_ENABLED: true,
OIDC_ISSUER_URL: "https://issuer.example.com",
OIDC_CLIENT_ID: "medassist-client",
OIDC_CLIENT_SECRET: "medassist-client-secret",
OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback",
OIDC_SCOPES: "openid profile email",
OIDC_AUTO_CREATE_USERS: true,
OIDC_USERNAME_CLAIM: "preferred_username",
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
CORS_ORIGINS: "http://localhost:5173",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
...envOverrides,
};
vi.doMock("../plugins/env.js", () => ({ env }));
vi.doMock("../db/client.js", () => ({
db: {
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
insert: vi.fn(() => ({
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })),
})),
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })),
},
}));
const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" });
const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => {
const state = typeof params?.state === "string" ? params.state : "state";
return new URL(`https://issuer.example.com/authorize?state=${state}`);
});
vi.doMock("openid-client", () => ({
discovery,
buildAuthorizationUrl,
authorizationCodeGrant: vi.fn(),
fetchUserInfo: vi.fn(),
}));
const { oidcRoutes } = await import("../routes/oidc.js");
const app = Fastify({ logger: false });
await app.register(cookie, { secret: "test-cookie-secret" });
app.decorate("config", {
accessSecret: "test-jwt-secret-12345",
refreshSecret: "test-refresh-secret-12345",
accessTtl: 15 * 60,
refreshTtl: 7 * 24 * 60 * 60,
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" },
});
await app.register(oidcRoutes);
await app.ready();
return {
app,
mocks: { discovery, buildAuthorizationUrl } as OidcMocks,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("OIDC routes", () => {
it("returns 400 on login and callback when oidc is disabled", async () => {
const { app } = await buildOidcApp({ OIDC_ENABLED: false });
try {
const login = await app.inject({ method: "GET", url: "/auth/oidc/login" });
const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
expect(login.statusCode).toBe(400);
expect(callback.statusCode).toBe(400);
} finally {
await app.close();
}
});
it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => {
const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true });
try {
const res = await app.inject({ method: "GET", url: "/auth/oidc/login" });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toContain("https://issuer.example.com/authorize");
expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true);
expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true);
expect(mocks.discovery).toHaveBeenCalledTimes(1);
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1);
} finally {
await app.close();
}
});
it("redirects with provider error when callback contains error params", async () => {
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
try {
const res = await app.inject({
method: "GET",
url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled",
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
} finally {
await app.close();
}
});
it("redirects when callback is missing required params", async () => {
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
try {
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
} finally {
await app.close();
}
});
it("redirects when callback state validation fails", async () => {
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
try {
const res = await app.inject({
method: "GET",
url: "/auth/oidc/callback?code=abc123&state=state123",
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
} finally {
await app.close();
}
});
});
+4 -1
View File
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
// Mock sendShoutrrrNotification from settings // Mock sendShoutrrrNotification from settings
vi.mock("../routes/settings.js", async (importOriginal) => { vi.mock("../routes/settings.js", async (importOriginal) => {
const original = (await importOriginal()) as any; const original = (await importOriginal()) as Record<string, unknown>;
return { return {
...original, ...original,
sendShoutrrrNotification: mockSendShoutrrr, sendShoutrrrNotification: mockSendShoutrrr,
@@ -149,6 +149,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1, share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
last_auto_email_sent text, last_auto_email_sent text,
last_notification_type text, last_notification_type text,
last_notification_channel text, last_notification_channel text,
+422
View File
@@ -0,0 +1,422 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
const env = {
AUTH_ENABLED: false,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
};
return {
testClient: client,
testDb: db,
mockedEnv: env,
nodemailerSendMail: vi.fn(),
fetchMock: vi.fn(),
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("../plugins/auth.js", () => ({
requireAuth: async () => {},
getAnonymousUserId: async () => 1,
}));
vi.mock("nodemailer", () => ({
default: {
createTransport: () => ({
sendMail: nodemailerSendMail,
}),
},
}));
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
const { exportRoutes } = await import("../routes/export.js");
const { reportRoutes } = await import("../routes/report.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM users");
}
async function seedAnonymousUser() {
await testClient.execute({
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
args: [1, "anon", "anonymous"],
});
}
async function seedMedication(name = "Aspirin") {
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
1,
name,
"Acetylsalicylic acid",
JSON.stringify(["Daniel"]),
"blister",
2,
2,
10,
3,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify(["2026-01-01T08:00:00.000Z"]),
JSON.stringify([
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", takenBy: "Daniel", intakeRemindersEnabled: true },
]),
0,
1,
],
});
return result.rows[0].id as number;
}
describe("Real route coverage: settings/export/report", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
await app.register(settingsRoutes);
await app.register(exportRoutes);
await app.register(reportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
await clearTables();
await seedAnonymousUser();
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_TOKEN;
delete process.env.SMTP_PASS;
delete process.env.SMTP_FROM;
delete process.env.SMTP_PORT;
delete process.env.SMTP_SECURE;
});
it("GET /settings creates defaults for anonymous user", async () => {
const response = await app.inject({ method: "GET", url: "/settings" });
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.language).toBe("en");
expect(body.shareStockStatus).toBe(true);
expect(body.upcomingTodayOnly).toBe(false);
expect(body.shareScheduleTodayOnly).toBe(false);
});
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
const response = await app.inject({
method: "PUT",
url: "/settings",
payload: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: true,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
},
});
expect(response.statusCode).toBe(200);
const stored = await testClient.execute({
sql: "SELECT repeat_daily_reminders FROM user_settings WHERE user_id = 1",
});
expect(stored.rows[0].repeat_daily_reminders).toBe(0);
});
it("PUT /settings/language validates supported language", async () => {
const response = await app.inject({
method: "PUT",
url: "/settings/language",
payload: { language: "fr" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("Invalid language");
});
it("POST /settings/test-email fails when SMTP is not configured", async () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("SMTP not configured");
});
it("POST /settings/test-email sends email when SMTP is configured", async () => {
process.env.SMTP_HOST = "smtp.example.com";
process.env.SMTP_USER = "mailer@example.com";
process.env.SMTP_TOKEN = "secret";
nodemailerSendMail.mockResolvedValue(undefined);
const response = await app.inject({
method: "POST",
url: "/settings/test-email",
payload: { email: "person@example.com" },
});
expect(response.statusCode).toBe(200);
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
});
it("POST /settings/test-shoutrrr validates URL presence", async () => {
const response = await app.inject({
method: "POST",
url: "/settings/test-shoutrrr",
payload: { url: "" },
});
expect(response.statusCode).toBe(400);
});
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
expect(result.success).toBe(false);
expect(result.error).toContain("not allowed");
});
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
fetchMock.mockResolvedValue({ ok: true });
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/mytopic",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /),
}),
method: "POST",
redirect: "error",
})
);
});
it("sendShoutrrrNotification uses JSON payload for webhook URLs", async () => {
fetchMock.mockResolvedValue({ ok: true });
const result = await sendShoutrrrNotification("https://hooks.slack.com/services/a/b/c", "Title", "Body");
expect(result.success).toBe(true);
const call = fetchMock.mock.calls[0];
expect(call[1].headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
});
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
await seedMedication("Owned Med");
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [9999] },
});
expect(response.statusCode).toBe(403);
});
it("POST /medications/report-data aggregates doses and refills", async () => {
const medId = await seedMedication("Report Med");
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, 0],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000600000-Daniel`, 1700000600, 1],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 1, 2, 1, 1700001200],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId] },
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body[medId].dosesTaken).toBe(1);
expect(body[medId].dosesDismissed).toBe(1);
expect(body[medId].refills).toHaveLength(1);
});
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
const medId = await seedMedication("Export Med");
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, "Daniel"],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 1, 3, 0, 1700000000],
});
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, email_enabled, notification_email, share_stock_status, language) VALUES (?, ?, ?, ?, ?)",
args: [1, 1, "x@example.com", 1, "de"],
});
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)",
args: [1, "abc123", "Daniel", 30],
});
const response = await app.inject({
method: "GET",
url: "/export?includeSensitive=true&includeImages=false",
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.medications).toHaveLength(1);
expect(body.doseHistory).toHaveLength(1);
expect(body.refillHistory).toHaveLength(1);
expect(body.settings.language).toBe("de");
expect(body.shareLinks).toHaveLength(1);
});
it("POST /import validates payload and imports minimal valid structure", async () => {
const invalid = await app.inject({
method: "POST",
url: "/import",
payload: { foo: "bar" },
});
expect(invalid.statusCode).toBe(400);
const validImport = {
version: "1.1",
exportedAt: new Date().toISOString(),
includeSensitiveData: false,
medications: [
{
_exportId: "med-1",
name: "Imported Med",
genericName: null,
takenBy: ["Daniel"],
inventory: {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
totalPills: null,
looseTablets: 0,
stockAdjustment: 0,
packageType: "blister",
},
pillWeightMg: null,
doseUnit: "mg",
schedules: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", remind: false, takenBy: "Daniel" }],
medicationStartDate: "",
expiryDate: null,
notes: null,
intakeRemindersEnabled: false,
isObsolete: false,
obsoleteAt: null,
prescriptionEnabled: false,
prescriptionAuthorizedRefills: null,
prescriptionRemainingRefills: null,
prescriptionLowRefillThreshold: 1,
prescriptionExpiryDate: null,
dismissedUntil: null,
image: null,
lastStockCorrectionAt: null,
},
],
doseHistory: [],
refillHistory: [],
settings: {
emailEnabled: false,
notificationEmail: null,
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrEnabled: false,
shoutrrrUrl: null,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
expiryWarningDays: 30,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
},
shareLinks: [],
};
const valid = await app.inject({
method: "POST",
url: "/import",
payload: validImport,
});
expect(valid.statusCode).toBe(200);
expect(valid.json().imported.medications).toBe(1);
const rows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = 1",
});
expect(rows.rows[0].name).toBe("Imported Med");
});
});
+16 -8
View File
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import Fastify from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
// Import from utils to avoid index.ts import side effects (server start) // Import from utils to avoid index.ts import side effects (server start)
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
refreshCookieOptions, refreshCookieOptions,
}); });
expect((app as any).config.accessTtl).toBe(15); const appWithConfig = app as unknown as {
expect((app as any).config.refreshTtl).toBe(7); config: {
expect((app as any).config.cookieOptions.httpOnly).toBe(true); accessTtl: number;
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60); refreshTtl: number;
cookieOptions: { httpOnly: boolean };
refreshCookieOptions: { maxAge: number };
};
};
expect(appWithConfig.config.accessTtl).toBe(15);
expect(appWithConfig.config.refreshTtl).toBe(7);
expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true);
expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
await app.close(); await app.close();
}); });
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
// Mock route plugins // Mock route plugins
const healthRoutes = async (app: any) => { const healthRoutes = async (app: FastifyInstance) => {
app.get("/health", async () => ({ status: "ok" })); app.get("/health", async () => ({ status: "ok" }));
}; };
const authRoutes = async (app: any) => { const authRoutes = async (app: FastifyInstance) => {
app.post("/auth/login", async () => ({ token: "mock" })); app.post("/auth/login", async () => ({ token: "mock" }));
}; };
const medicationRoutes = async (app: any) => { const medicationRoutes = async (app: FastifyInstance) => {
app.get("/medications", async () => []); app.get("/medications", async () => []);
}; };
+2 -2
View File
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
const data = response.json(); const data = response.json();
expect(data).toHaveLength(2); expect(data).toHaveLength(2);
const medA = data.find((d: any) => d.medicationName === "Med A"); const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
const medB = data.find((d: any) => d.medicationName === "Med B"); const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
@@ -0,0 +1,350 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: false,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
vi.mock("../plugins/auth.js", () => ({
requireAuth: async () => {},
getAnonymousUserId: async () => 1,
}));
const { medicationRoutes } = await import("../routes/medications.js");
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM users");
}
async function seedAnonymousUser() {
await testClient.execute({
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
args: [1, "anon", "anonymous"],
});
}
async function setStockMode(mode: "automatic" | "manual") {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
VALUES (?, ?, 7, 365, 'en')`,
args: [1, mode],
});
}
async function createMedication(options: {
name: string;
packCount?: number;
blistersPerPack?: number;
pillsPerBlister?: number;
looseTablets?: number;
stockAdjustment?: number;
lastStockCorrectionAt?: number | null;
isObsolete?: boolean;
takenBy?: string[];
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
}) {
const {
name,
packCount = 1,
blistersPerPack = 1,
pillsPerBlister = 10,
looseTablets = 0,
stockAdjustment = 0,
lastStockCorrectionAt = null,
isObsolete = false,
takenBy = [],
intakes,
} = options;
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
const everyJson = JSON.stringify(intakes.map((i) => i.every));
const startJson = JSON.stringify(intakes.map((i) => i.start));
const intakesJson = JSON.stringify(
intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
takenBy: i.takenBy ?? null,
intakeRemindersEnabled: false,
}))
);
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`,
args: [
1,
name,
JSON.stringify(takenBy),
packCount,
blistersPerPack,
pillsPerBlister,
looseTablets,
stockAdjustment,
lastStockCorrectionAt,
usageJson,
everyJson,
startJson,
intakesJson,
isObsolete ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
async function markDoseTaken(options: {
medicationId: number;
blisterIdx: number;
doseDateOnlyMs: number;
takenAtMs: number;
personSuffix?: string;
}) {
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
args: [1, doseId, Math.floor(takenAtMs / 1000)],
});
}
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: { startDate, endDate },
});
expect(response.statusCode).toBe(200);
const rows = response.json();
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
expect(row).toBeDefined();
return row;
}
function toDateOnlyMs(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
describe("Stock semantics parity (planner usage vs scheduler)", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false });
await app.register(medicationRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTables();
await seedAnonymousUser();
});
it("keeps automatic mode current stock in sync", async () => {
await setStockMode("automatic");
const medName = "Auto Sync";
await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(usageRow.totalPills);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
await setStockMode("manual");
const medName = "Manual Sync";
await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(10);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
await setStockMode("manual");
const medName = "Manual Correction";
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
const medicationId = await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
lastStockCorrectionAt: correctionMs,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: jan5DateOnly,
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
});
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: jan6DateOnly,
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("counts early taken dose in automatic mode without drift", async () => {
await setStockMode("automatic");
const medName = "Early Taken";
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
tomorrow.setHours(20, 0, 0, 0);
const medicationId = await createMedication({
name: medName,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
});
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
await markDoseTaken({
medicationId,
blisterIdx: 0,
doseDateOnlyMs: tomorrowDateOnly,
takenAtMs: now.getTime(),
});
const rangeStart = new Date(now);
rangeStart.setDate(now.getDate() - 1);
const rangeEnd = new Date(now);
rangeEnd.setDate(now.getDate() + 7);
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(9);
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
});
it("handles mixed intake-level and fallback takenBy consistently", async () => {
await setStockMode("automatic");
const medName = "Mixed TakenBy";
await createMedication({
name: medName,
packCount: 2,
blistersPerPack: 1,
pillsPerBlister: 10,
takenBy: ["Alice", "Bob"],
intakes: [
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
],
});
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
const schedulerRow = lowStock.find((r) => r.name === medName);
expect(schedulerRow).toBeDefined();
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
expect(usageRow.currentPills).toBeLessThan(20);
});
it("excludes obsolete medications from planner usage and scheduler", async () => {
await setStockMode("automatic");
await createMedication({
name: "Obsolete Med",
isObsolete: true,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
});
expect(response.statusCode).toBe(200);
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
});
});
+3 -3
View File
@@ -191,7 +191,7 @@ export function parseIntakesJson(
try { try {
const parsed = JSON.parse(intakesJson); const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: any) => ({ return parsed.map((intake: Record<string, unknown>) => ({
usage: typeof intake.usage === "number" ? intake.usage : 0, usage: typeof intake.usage === "number" ? intake.usage : 0,
every: typeof intake.every === "number" ? intake.every : 1, every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(), start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
@@ -312,7 +312,7 @@ export type UpcomingIntake = {
export function getTodaysIntakes( export function getTodaysIntakes(
medName: string, medName: string,
intakes: Intake[], intakes: Intake[],
medicationTakenBy: string[], // Medication-level takenBy as fallback _medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null, pillWeightMg: number | null,
locale: string, locale: string,
tz?: string, tz?: string,
@@ -388,7 +388,7 @@ export function getUpcomingIntakes(
medName: string, medName: string,
intakes: Intake[], intakes: Intake[],
minutesBefore: number, minutesBefore: number,
medicationTakenBy: string[], // Medication-level takenBy as fallback _medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null, pillWeightMg: number | null,
locale: string, locale: string,
tz?: string, tz?: string,
+2 -1
View File
@@ -16,7 +16,8 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "off",
"noImportantStyles": "off"
}, },
"suspicious": { "suspicious": {
"noExplicitAny": "warn", "noExplicitAny": "warn",
+2
View File
@@ -30,6 +30,8 @@ services:
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- frontend_node_modules:/app/node_modules - frontend_node_modules:/app/node_modules
env_file:
- .env
environment: environment:
- BACKEND_URL=http://backend-dev:3000 - BACKEND_URL=http://backend-dev:3000
ports: ports:
+11 -9
View File
@@ -2,7 +2,6 @@ import {
authFile, authFile,
createMedicationViaAPI, createMedicationViaAPI,
deleteAllMedicationsViaAPI, deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect, expect,
navigateTo, navigateTo,
type TestMedication, type TestMedication,
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
await expect(ibuprofenRow).toBeVisible(); await expect(ibuprofenRow).toBeVisible();
const rowText = await ibuprofenRow.textContent(); const rowText = await ibuprofenRow.textContent();
// Stock should show around 59-60 (60 pills minus today's consumed dose) // Stock should show around 59-60 (60 pills minus today's consumed dose)
expect(rowText).toContain("59"); expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy();
}); });
test("should show today block in timeline", async ({ page }) => { test("should show today block in timeline", async ({ page }) => {
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
await expect(todayBlock).toBeVisible({ timeout: 10000 }); await expect(todayBlock).toBeVisible({ timeout: 10000 });
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
if (!(await takeBtn.isVisible().catch(() => false))) return; test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
await takeBtn.click(); await takeBtn.click();
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 }); await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
@@ -154,20 +153,23 @@ test.describe("Dashboard with medications", () => {
const todayBlock = page.locator(".day-block.today"); const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible({ timeout: 15000 }); await expect(todayBlock).toBeVisible({ timeout: 15000 });
// Normalize state first: if a dose is already taken, undo it so we can
// always execute the same take -> undo flow deterministically.
const existingUndo = todayBlock.locator("button.dose-btn.undo").first();
if (await existingUndo.isVisible().catch(() => false)) {
await existingUndo.click();
await page.waitForLoadState("networkidle");
}
// Mark a dose as taken first // Mark a dose as taken first
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
if (!(await takeBtn.isVisible().catch(() => false))) return; await expect(takeBtn).toBeVisible({ timeout: 10000 });
await takeBtn.click(); await takeBtn.click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Wait for undo button to appear (confirms the take succeeded) // Wait for undo button to appear (confirms the take succeeded)
const undoBtn = todayBlock.locator("button.dose-btn.undo").first(); const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
try {
await expect(undoBtn).toBeVisible({ timeout: 10000 }); await expect(undoBtn).toBeVisible({ timeout: 10000 });
} catch {
// Take might have been rate-limited — skip this test gracefully
return;
}
await undoBtn.click(); await undoBtn.click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
+1 -1
View File
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
* auth.spec.ts should keep importing from `@playwright/test` directly * auth.spec.ts should keep importing from `@playwright/test` directly
* since it tests the unauthenticated flow. * since it tests the unauthenticated flow.
*/ */
export const test = base.extend<{}>({ export const test = base.extend<object>({
page: async ({ page }, use) => { page: async ({ page }, use) => {
await setupAuthMeMock(page); await setupAuthMeMock(page);
await use(page); await use(page);
+94 -73
View File
@@ -38,57 +38,57 @@ async function fillAndSaveMedication(
intakes?: { usage: string; every: string }[]; intakes?: { usage: string; every: string }[];
} }
): Promise<void> { ): Promise<void> {
await page.getByLabel(/Commercial Name/i).fill(opts.name); const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
if (await openCreateBtn.isVisible().catch(() => false)) {
await openCreateBtn.click();
}
const form = page.locator("form.form-grid:visible").first();
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
if (opts.genericName) { if (opts.genericName) {
await page.getByLabel(/Generic Name/i).fill(opts.genericName); await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
} }
const packageTypeSelect = form.locator("select.package-type-select");
if (opts.packageType === "bottle") { if (opts.packageType === "bottle") {
await page.locator("select.package-type-select").selectOption("bottle"); await packageTypeSelect.selectOption("bottle");
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity); await page.getByRole("tab", { name: /Package/i }).click();
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills); if (opts.totalCapacity)
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
} else { } else {
await page.locator("select.package-type-select").selectOption("blister"); await packageTypeSelect.selectOption("blister");
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs); await page.getByRole("tab", { name: /Package/i }).click();
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack); if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister); if (opts.blistersPerPack)
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills); await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
if (opts.pillsPerBlister)
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
if (opts.loosePills) {
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
if (await looseField.isVisible().catch(() => false)) {
await looseField.fill(opts.loosePills);
}
}
} }
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate); if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes); if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
// Fill intake schedules // Fill intake schedules
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }]; const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
await page.getByRole("tab", { name: /Schedule/i }).click();
for (let i = 0; i < intakes.length; i++) { for (let i = 0; i < intakes.length; i++) {
if (i > 0) { if (i > 0) {
await page.getByRole("button", { name: /Intake/i }).click(); await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
} }
const row = page.locator(".blister-row").nth(i); const row = form.locator(".blister-row").nth(i);
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage); await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every); await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
} }
// Click Save — handle potential rate-limiting by retrying
for (let attempt = 0; attempt < 3; attempt++) {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.locator("form.form-grid button[type='submit']").click(); await form.locator("button[type='submit']").click();
// Wait for the form to reset: commercial name becomes empty after successful save
try {
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
break; // Save succeeded
} catch {
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
// Save might have been rate-limited — wait and retry
await page.waitForTimeout(3000);
// Re-fill the name in case form was partially reset
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
if (!currentValue) {
await page.getByLabel(/Commercial Name/i).fill(opts.name);
}
}
}
// Verify the medication appears in the list (may need reload if GET was rate-limited) // Verify the medication appears in the list (may need reload if GET was rate-limited)
const medRow = page.locator(".med-row").filter({ hasText: opts.name }); const medRow = page.locator(".med-row").filter({ hasText: opts.name });
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
* Helper: save after editing (PUT) and wait for success. * Helper: save after editing (PUT) and wait for success.
*/ */
async function saveEdit(page: Page, medName: string): Promise<void> { async function saveEdit(page: Page, medName: string): Promise<void> {
const form = page.locator("form.form-grid:visible").first();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.locator("form.form-grid button[type='submit']").click(); const submitBtn = form.locator("button[type='submit']");
if (
(await submitBtn.count()) > 0 &&
(await submitBtn
.first()
.isVisible()
.catch(() => false))
) {
await submitBtn.first().click();
} else {
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
}
}
// Wait for the list to update with the new name — retry with reload if rate-limited // Wait for the list to update with the new name — retry with reload if rate-limited
const medRow = page.locator(".med-row").filter({ hasText: medName }); const medRow = page.locator(".med-row").filter({ hasText: medName });
try { try {
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
test("should not save with empty commercial name", async ({ page }) => { test("should not save with empty commercial name", async ({ page }) => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.first()
.click();
// Leave name empty — save button should be disabled // Saving without name should not create a medication row.
const saveBtn = page.locator("form.form-grid button[type='submit']"); const saveBtn = page.locator("form.form-grid button[type='submit']");
await expect(saveBtn).toBeDisabled(); await expect(saveBtn).toBeVisible();
await saveBtn.click();
await expect(page.locator(".med-row")).toHaveCount(0);
}); });
test("should reset form after saving a medication", async ({ page }) => { test("should reset form after saving a medication", async ({ page }) => {
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
pillsPerBlister: "10", pillsPerBlister: "10",
}); });
// Form should reset — title should say "New medication" // Opening a fresh form after save should start with an empty commercial name.
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 }); await page
// Commercial name should be empty .getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue(""); .first()
.click();
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("");
}); });
}); });
@@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => {
await expect(medRow).toBeVisible({ timeout: 10000 }); await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click(); await medRow.locator("button.info").click();
// Form title should say "Edit medication" // Form title should say "Edit entry" (or legacy "Edit medication").
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible(); await expect(
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
).toBeVisible();
// The name field should have the current value // The name field should have the current value
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit"); await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
// Change the name // Change the name
await page.getByLabel(/Commercial Name/i).fill("After Edit"); await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
// Save the edit // Save the edit
await saveEdit(page, "After Edit"); await saveEdit(page, "After Edit");
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
await medRow.locator("button.info").click(); await medRow.locator("button.info").click();
// Change the name // Change the name
await page.getByLabel(/Commercial Name/i).fill("Modified Name"); await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name");
// Click Cancel // Click Cancel
await page.locator("form.form-grid button.ghost").click(); await page
.getByRole("button", { name: /Close|Cancel/i })
.first()
.click();
// Original name should still be in the list // Original name should still be in the list
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible(); await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
}); });
test("should show refill section in edit mode", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
await navigateTo(page, "/medications");
// Click Edit
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click();
// Refill section should be visible
const refillSection = page.locator(".refill-section");
await expect(refillSection).toBeVisible();
await expect(refillSection.locator("button.success")).toBeVisible();
});
}); });
test.describe("Delete medication", () => { test.describe("Delete medication", () => {
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" }); const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
await expect(medRow).toBeVisible({ timeout: 10000 }); await expect(medRow).toBeVisible({ timeout: 10000 });
// Accept the native confirm() dialog
page.on("dialog", (dialog) => dialog.accept());
await medRow.locator("button.danger").click(); await medRow.locator("button.danger").click();
await page
.locator(".confirm-modal-overlay, .modal-overlay")
.getByRole("button", { name: /Delete/i })
.click();
// Medication should be removed // Medication should be removed
await expect(medRow).not.toBeVisible({ timeout: 5000 }); await expect(medRow).toHaveCount(0, { timeout: 10000 });
// Already deleted via UI — clear tracked list // Already deleted via UI — clear tracked list
createdMeds.length = 0; createdMeds.length = 0;
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
test.describe("Intake schedule management", () => { test.describe("Intake schedule management", () => {
test("should add and remove intake schedule rows", async ({ page }) => { test("should add and remove intake schedule rows", async ({ page }) => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.first()
.click();
await page.getByRole("tab", { name: /Schedule/i }).click();
const form = page.locator("form.form-grid:visible").first();
expect(await page.locator(".blister-row").count()).toBe(1); expect(await form.locator(".blister-row").count()).toBe(1);
await page.getByRole("button", { name: /Intake/i }).click(); await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await page.locator(".blister-row").count()).toBe(2); expect(await form.locator(".blister-row").count()).toBe(2);
await page.getByRole("button", { name: /Intake/i }).click(); await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await page.locator(".blister-row").count()).toBe(3); expect(await form.locator(".blister-row").count()).toBe(3);
const removeBtn = page const removeBtn = page
.locator(".blister-row") .locator("form.form-grid:visible .blister-row")
.last() .last()
.getByRole("button", { name: /Remove/i }); .getByRole("button", { name: /Remove/i });
await removeBtn.click(); await removeBtn.click();
expect(await page.locator(".blister-row").count()).toBe(2); expect(await form.locator(".blister-row").count()).toBe(2);
}); });
}); });
}); });
+55 -64
View File
@@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
} }
await expect(medRow).toBeVisible({ timeout: 10000 }); await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click(); await medRow.locator("button.info").click();
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 }); await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
timeout: 5000,
});
} }
/** Helper: save edit and verify success */ /** Helper: save edit and verify success */
async function saveEditAndVerify(page: Page, medName: string): Promise<void> { async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
const form = page.locator("form.form-grid:visible").first();
// Wait for any pending network before clicking save // Wait for any pending network before clicking save
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Click save const submitBtn = form.locator("button[type='submit']");
const saveBtn = page.locator("form.form-grid button[type='submit']"); if (
await saveBtn.click(); (await submitBtn.count()) > 0 &&
(await submitBtn
.first()
.isVisible()
.catch(() => false))
) {
await submitBtn.first().click();
} else {
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
}
}
// Wait for save request + re-fetch to complete // Wait for save request + re-fetch to complete
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
await clickEditMed(page, "Edit GenName Med"); await clickEditMed(page, "Edit GenName Med");
// Generic name should be empty initially // Generic name should be empty initially
const genericField = page.getByLabel(/Generic Name/i); const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i);
await expect(genericField).toHaveValue(""); await expect(genericField).toHaveValue("");
// Add a generic name // Add a generic name
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
// Click edit again and verify the generic name was saved // Click edit again and verify the generic name was saved
await clickEditMed(page, "Edit GenName Med"); await clickEditMed(page, "Edit GenName Med");
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid"); await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid");
}); });
test("should add notes to an existing medication", async ({ page }) => { test("should add notes to an existing medication", async ({ page }) => {
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "Edit Notes Med"); await clickEditMed(page, "Edit Notes Med");
await page.getByRole("tab", { name: /Package/i }).click();
// Notes should be empty initially // Notes should be empty initially
const notesField = page.getByLabel(/Notes/i); const notesField = page.getByLabel(/(Notes|form\.notes)/i);
await expect(notesField).toHaveValue(""); await expect(notesField).toHaveValue("");
// Add notes text // Add notes text
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
// Verify notes were saved by clicking edit again // Verify notes were saved by clicking edit again
await clickEditMed(page, "Edit Notes Med"); await clickEditMed(page, "Edit Notes Med");
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast"); await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast");
}); });
test("should add taken-by person to a medication", async ({ page }) => { test("should add taken-by person to a medication", async ({ page }) => {
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "Expiry Date Med"); await clickEditMed(page, "Expiry Date Med");
await page.getByRole("tab", { name: /Package/i }).click();
// Set expiry date to 6 months from now // Set expiry date to 6 months from now
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const expiryField = page.getByLabel(/Expiry Date/i); const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i);
await expiryField.fill(expiryDate); await expiryField.fill(expiryDate);
await expect(expiryField).toHaveValue(expiryDate); await expect(expiryField).toHaveValue(expiryDate);
// Also touch the name field to ensure form is dirty // Also touch the name field to ensure form is dirty
const nameField = page.getByLabel(/Commercial Name/i); // Expiry change itself is enough to persist in the current edit flow.
const currentName = await nameField.inputValue();
await nameField.fill(currentName);
await saveEditAndVerify(page, "Expiry Date Med"); await saveEditAndVerify(page, "Expiry Date Med");
// Verify expiry date was saved // Verify expiry date was saved
await clickEditMed(page, "Expiry Date Med"); await clickEditMed(page, "Expiry Date Med");
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate); await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
});
test("should use refill feature to add stock in edit mode", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Refill Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Refill Test Med");
// Refill section should be visible in edit mode
const refillSection = page.locator(".refill-section");
await expect(refillSection).toBeVisible();
// Set refill values: 2 packs + 5 loose pills
await refillSection.getByLabel(/Packs/i).fill("2");
await refillSection.getByLabel(/Loose pills/i).fill("5");
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
const preview = refillSection.locator(".refill-preview");
await expect(preview).toBeVisible();
expect(await preview.textContent()).toContain("45");
// Click the refill button
await refillSection.locator("button.success").click();
// Wait for the refill to be processed
await page.waitForLoadState("networkidle");
}); });
test("should edit intake schedule usage and interval", async ({ page }) => { test("should edit intake schedule usage and interval", async ({ page }) => {
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "Edit Intake Med"); await clickEditMed(page, "Edit Intake Med");
await page.getByRole("tab", { name: /Schedule/i }).click();
// Change intake from 1 pill daily to 2 pills every 7 days // Change intake from 1 pill daily to 2 pills every 7 days
const intakeRow = page.locator(".blister-row").first(); const intakeRow = page.locator(".blister-row").first();
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i); const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
const everyField = intakeRow.getByLabel(/Every \(days\)/i); const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill("2"); await usageField.fill("2");
await everyField.fill("7"); await everyField.fill("7");
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
// Verify the changes persisted // Verify the changes persisted
await clickEditMed(page, "Edit Intake Med"); await clickEditMed(page, "Edit Intake Med");
const savedRow = page.locator(".blister-row").first(); const savedRow = page.locator(".blister-row").first();
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2"); await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7"); await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
}); });
test("should add a second intake schedule row", async ({ page }) => { test("should add a second intake schedule row", async ({ page }) => {
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "Add Intake Med"); await clickEditMed(page, "Add Intake Med");
await page.getByRole("tab", { name: /Schedule/i }).click();
// Should have 1 intake row initially // Should have 1 intake row initially
await expect(page.locator(".blister-row")).toHaveCount(1); await expect(page.locator(".blister-row")).toHaveCount(1);
// Add a second intake // Add a second intake
await page.getByRole("button", { name: /Intake/i }).click(); await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
await expect(page.locator(".blister-row")).toHaveCount(2); await expect(page.locator(".blister-row")).toHaveCount(2);
// Fill the new intake row // Fill the new intake row
const secondRow = page.locator(".blister-row").nth(1); const secondRow = page.locator(".blister-row").nth(1);
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5"); await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/Every \(days\)/i).fill("7"); await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
await saveEditAndVerify(page, "Add Intake Med"); await saveEditAndVerify(page, "Add Intake Med");
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "Reminder Toggle Med"); await clickEditMed(page, "Reminder Toggle Med");
await page.getByRole("tab", { name: /Schedule/i }).click();
// Find the remind checkbox in the intake row // Find the remind checkbox in the intake row
const intakeRow = page.locator(".blister-row").first(); const intakeRow = page.locator(".blister-row").first();
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
await clickEditMed(page, "PackType Change Med"); await clickEditMed(page, "PackType Change Med");
const form = page.locator("form.form-grid:visible").first();
// Should be blister type initially // Should be blister type initially
const packageSelect = page.locator("select.package-type-select"); const packageSelect = form.locator("select.package-type-select");
await expect(packageSelect).toHaveValue("blister"); await expect(packageSelect).toHaveValue("blister");
// Blister-specific fields should be visible // Blister-specific fields are shown in the Package tab.
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible(); await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to bottle // Switch to bottle
await packageSelect.selectOption("bottle"); await packageSelect.selectOption("bottle");
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible(); await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
// Fill bottle-specific fields // Fill bottle-specific fields
await page.getByLabel(/Total Capacity/i).fill("120"); await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
await saveEditAndVerify(page, "PackType Change Med"); await saveEditAndVerify(page, "PackType Change Med");
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
await clickEditMed(page, "Multi Edit Med"); await clickEditMed(page, "Multi Edit Med");
// Change the name // Change the name
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med"); await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med");
// Add generic name // Add generic name
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate"); await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
// Add notes // Add notes
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water."); await page.getByRole("tab", { name: /Package/i }).click();
await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water.");
await page.getByRole("tab", { name: /General/i }).click();
// Add a taken-by person // Add a taken-by person
const takenByInput = page.locator(".tag-input-container input"); const takenByInput = page.locator(".tag-input-container input");
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
// Verify all changes persisted // Verify all changes persisted
await clickEditMed(page, "Fully Edited Med"); await clickEditMed(page, "Fully Edited Med");
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med"); await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate"); await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only"); await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible(); await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
}); });
}); });
+45 -31
View File
@@ -10,11 +10,17 @@ import { authFile, navigateTo, test } from "./fixtures";
test.describe("Medications Page", () => { test.describe("Medications Page", () => {
test.use({ storageState: authFile }); test.use({ storageState: authFile });
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
async function openMedicationForm(page: Page) { async function openMedicationForm(page: Page) {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
const newMedicationButton = page.getByRole("button", { name: /New medication/i }); const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
if (await newMedicationButton.isVisible().catch(() => false)) { if (await nameField.isVisible().catch(() => false)) return;
await newMedicationButton.click();
const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i });
if (await newEntryButton.isVisible().catch(() => false)) {
await newEntryButton.click();
await expect(nameField).toBeVisible({ timeout: 5000 });
} }
} }
@@ -29,8 +35,8 @@ test.describe("Medications Page", () => {
await navigateTo(page, "/medications"); await navigateTo(page, "/medications");
// Should show either medication entries or the new medication form // Should show either medication entries or the new medication form
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i }); const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
const formTitle = page.locator("h2").filter({ hasText: /New medication/i }); const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
const hasList = await listTitle.isVisible().catch(() => false); const hasList = await listTitle.isVisible().catch(() => false);
const hasForm = await formTitle.isVisible().catch(() => false); const hasForm = await formTitle.isVisible().catch(() => false);
@@ -40,85 +46,92 @@ test.describe("Medications Page", () => {
test("should display the medication form with required fields", async ({ page }) => { test("should display the medication form with required fields", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
const commercialName = page.getByLabel(/Commercial Name/i); const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
await expect(commercialName).toBeVisible(); await expect(commercialName).toBeVisible();
// Package type selector should exist // Package type selector should exist
await expect(page.getByText(/Package Type/i)).toBeVisible(); await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible();
// Intake schedule section should exist // Tabbed form should expose navigation to Package/Schedule sections
await expect(page.getByText(/Intake schedule/i)).toBeVisible(); await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible();
await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible();
}); });
test("should fill in medication details", async ({ page }) => { test("should fill in medication details", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
const nameField = page.getByLabel(/Commercial Name/i); const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
await nameField.fill("Test Aspirin"); await nameField.fill("Test Aspirin");
await expect(nameField).toHaveValue("Test Aspirin"); await expect(nameField).toHaveValue("Test Aspirin");
const genericField = page.getByLabel(/Generic Name/i); const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i);
await genericField.fill("Acetylsalicylic acid"); await genericField.fill("Acetylsalicylic acid");
await expect(genericField).toHaveValue("Acetylsalicylic acid"); await expect(genericField).toHaveValue("Acetylsalicylic acid");
}); });
test("should have stock inventory fields", async ({ page }) => { test("should have stock inventory fields", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Package/i }).click();
// Stock fields should be visible // Package tab should expose stock-related fields for at least one package mode.
await expect(page.getByLabel(/^Packs$/i)).toBeVisible(); const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first();
const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first();
// Either blister or bottle fields depending on package type const hasPacks = await packsField.isVisible().catch(() => false);
const blistersField = page.getByLabel(/Blisters per pack/i); const hasTotal = await totalField.isVisible().catch(() => false);
const pillsField = page.getByLabel(/Pills per blister/i);
const capacityField = page.getByLabel(/Total Capacity/i);
const hasBlister = await blistersField.isVisible().catch(() => false); expect(hasPacks || hasTotal).toBeTruthy();
const hasBottle = await capacityField.isVisible().catch(() => false);
expect(hasBlister || hasBottle).toBeTruthy();
}); });
test("should toggle package type between blister and bottle", async ({ page }) => { test("should toggle package type between blister and bottle", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Package/i }).click();
// Find the package type radio buttons or selector // Find the package type radio buttons or selector
const blisterOption = page.getByText(/Blister Pack/i); const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
const bottleOption = page.getByText(/Pill Bottle/i); const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
if (await blisterOption.isVisible().catch(() => false)) { if (await blisterOption.isVisible().catch(() => false)) {
// Switch to bottle // Switch to bottle
await bottleOption.click(); await bottleOption.click();
// Bottle-specific fields should appear // Bottle-specific fields should appear
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible(); await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
// Switch back to blister // Switch back to blister
await blisterOption.click(); await blisterOption.click();
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible(); await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
} }
}); });
test("should have intake schedule with add button", async ({ page }) => { test("should have intake schedule with add button", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Schedule/i }).click();
// Intake schedule section // Intake schedule section
const scheduleSection = page.getByText(/Intake schedule/i); await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
await expect(scheduleSection).toBeVisible();
// Should have at least one intake entry // Should have at least one intake entry
await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible(); await expect(
form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first()
).toBeVisible();
// Should have an add intake button // Should have an add intake button
const addIntake = page.getByRole("button", { name: /Intake/i }); const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
await expect(addIntake).toBeVisible(); await expect(addIntake).toBeVisible();
}); });
test("should have save and cancel buttons", async ({ page }) => { test("should have save and cancel buttons", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
// Fill in a name to make the form dirty // Fill in a name to make the form dirty
await page.getByLabel(/Commercial Name/i).fill("Test"); await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test");
// Save button // Save button
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i }); const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
@@ -127,9 +140,10 @@ test.describe("Medications Page", () => {
test("should prevent navigation with unsaved changes", async ({ page }) => { test("should prevent navigation with unsaved changes", async ({ page }) => {
await openMedicationForm(page); await openMedicationForm(page);
const form = visibleMedForm(page);
// Fill in the form to create unsaved changes // Fill in the form to create unsaved changes
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication"); await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication");
// Try to navigate away // Try to navigate away
await page.locator('button.pill:has-text("Dashboard")').click(); await page.locator('button.pill:has-text("Dashboard")').click();
-1
View File
@@ -3,7 +3,6 @@ import {
authFile, authFile,
createMedicationViaAPI, createMedicationViaAPI,
deleteAllMedicationsViaAPI, deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect, expect,
navigateTo, navigateTo,
type TestMedication, type TestMedication,
+1 -2
View File
@@ -2,7 +2,6 @@ import {
authFile, authFile,
createMedicationViaAPI, createMedicationViaAPI,
deleteAllMedicationsViaAPI, deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect, expect,
navigateTo, navigateTo,
type TestMedication, type TestMedication,
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
await expect(todayBlock).toBeVisible({ timeout: 15000 }); await expect(todayBlock).toBeVisible({ timeout: 15000 });
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first(); const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
if (!(await takeBtn.isVisible().catch(() => false))) return; test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
await takeBtn.click(); await takeBtn.click();
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
+45 -39
View File
@@ -1,5 +1,5 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import { authFile, navigateTo, test } from "./fixtures"; import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
/** /**
* Schedule / Timeline E2E Tests * Schedule / Timeline E2E Tests
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
test.describe("Schedule Timeline", () => { test.describe("Schedule Timeline", () => {
test.use({ storageState: authFile }); test.use({ storageState: authFile });
const seededName = "Schedule Smoke Seed";
const startThreeDaysAgo = (() => {
const d = new Date();
d.setDate(d.getDate() - 3);
d.setHours(8, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: seededName,
packageType: "blister",
packCount: 2,
blistersPerPack: 2,
pillsPerBlister: 10,
takenBy: ["Daniel"],
intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }],
});
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("should have timeline container in DOM", async ({ page }) => { test("should have timeline container in DOM", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
test("should show past days toggle when medications exist", async ({ page }) => { test("should show past days toggle when medications exist", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
// Past days toggle only appears when there are scheduled medications // Past days toggle appears when there are scheduled medications
const pastToggle = page.locator(".past-days-toggle"); const pastToggle = page.locator(".past-days-toggle");
const hasPastToggle = await pastToggle.isVisible().catch(() => false); await expect(pastToggle).toBeVisible();
// Just verify it doesn't crash — visibility depends on medication data
expect(typeof hasPastToggle).toBe("boolean");
}); });
test("should expand/collapse past days on click", async ({ page }) => { test("should expand/collapse past days on click", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
const pastToggle = page.locator(".past-days-toggle"); const pastToggle = page.locator(".past-days-toggle");
if (!(await pastToggle.isVisible().catch(() => false))) { await expect(pastToggle).toBeVisible();
// No medications — past days toggle not shown
return;
}
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded")); const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
@@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => {
test("should show future days toggle when medications exist", async ({ page }) => { test("should show future days toggle when medications exist", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
// Future days toggle only appears when there are scheduled medications // Future days toggle appears when there are scheduled medications
const futureToggle = page.locator(".future-days-toggle"); const futureToggle = page.locator(".future-days-toggle");
const hasFutureToggle = await futureToggle.isVisible().catch(() => false); await expect(futureToggle).toBeVisible();
expect(typeof hasFutureToggle).toBe("boolean");
}); });
test("should display day blocks in timeline", async ({ page }) => { test("should display day blocks in timeline", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
// There should be at least one day block (today) // With medications there should be day blocks; otherwise empty-state is expected.
const dayBlocks = page.locator(".day-block"); const dayBlocks = page.locator(".day-block");
expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0); const dayBlockCount = await dayBlocks.count();
if (dayBlockCount === 0) {
await expect(page.getByText(/No medications/i)).toBeVisible();
return;
}
expect(dayBlockCount).toBeGreaterThanOrEqual(1);
}); });
test("should highlight today block", async ({ page }) => { test("should highlight today block", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
// If there are medications, today should be highlighted // With medications, today should be highlighted
const todayBlock = page.locator(".day-block.today"); const todayBlock = page.locator(".day-block.today");
const hasTodayBlock = await todayBlock.isVisible().catch(() => false);
// Today block exists only if there are medications with schedules
if (hasTodayBlock) {
await expect(todayBlock).toBeVisible(); await expect(todayBlock).toBeVisible();
// Should have a day divider with date text
await expect(todayBlock.locator(".day-date")).toBeVisible(); await expect(todayBlock.locator(".day-date")).toBeVisible();
}
}); });
test("should show day summary with progress", async ({ page }) => { test("should show day summary with progress", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
const todayBlock = page.locator(".day-block.today"); const todayBlock = page.locator(".day-block.today");
if (await todayBlock.isVisible().catch(() => false)) { await expect(todayBlock).toBeVisible();
const summary = todayBlock.locator(".day-summary"); const summary = todayBlock.locator(".day-summary");
await expect(summary).toBeVisible(); await expect(summary).toBeVisible();
}
}); });
test("should collapse/expand a day block", async ({ page }) => { test("should collapse/expand a day block", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
const todayBlock = page.locator(".day-block.today"); const todayBlock = page.locator(".day-block.today");
if (await todayBlock.isVisible().catch(() => false)) { await expect(todayBlock).toBeVisible();
const dayDivider = todayBlock.locator(".day-divider"); const dayDivider = todayBlock.locator(".day-divider");
await dayDivider.click(); await dayDivider.click();
// Check if it toggled collapsed state
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
// Click again to restore
await dayDivider.click(); await dayDivider.click();
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed")); const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
expect(isCollapsed).not.toBe(isCollapsedAfter); expect(isCollapsed).not.toBe(isCollapsedAfter);
}
}); });
test("should show overview table with stock status", async ({ page }) => { test("should show overview table with stock status", async ({ page }) => {
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
// Overview table has class .table.table-7 // Overview table has class .table.table-7
const overviewTable = page.locator(".table.table-7"); const overviewTable = page.locator(".table.table-7");
const hasTable = await overviewTable.isVisible().catch(() => false); await expect(overviewTable).toBeVisible();
// Table only visible if medications exist
if (hasTable) {
// Table should have a header row
await expect(overviewTable.locator(".table-head")).toBeVisible(); await expect(overviewTable.locator(".table-head")).toBeVisible();
}
}); });
test("should display share button in schedules section", async ({ page }) => { test("should display share button in schedules section", async ({ page }) => {
await navigateTo(page, "/dashboard"); await navigateTo(page, "/dashboard");
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
const shareBtn = page.locator("button.share-btn"); const shareBtn = page.locator("button.share-btn");
// Share button only visible if there are takenBy users await expect(shareBtn).toBeVisible();
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
// Just verify it's either visible or not (no crash)
expect(typeof hasShareBtn).toBe("boolean");
}); });
}); });
+1 -4
View File
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
} }
} }
if (!enabledToggle) { test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
// All toggles disabled (no notification channels configured) — skip
return;
}
const checkbox = enabledToggle.locator('input[type="checkbox"]'); const checkbox = enabledToggle.locator('input[type="checkbox"]');
const initialState = await checkbox.isChecked(); const initialState = await checkbox.isChecked();
+1 -1
View File
@@ -160,7 +160,7 @@ test.describe("Share Schedule", () => {
// Should show the shared schedule page (not the login page) // Should show the shared schedule page (not the login page)
// Wait for either the schedule content or an error // Wait for either the schedule content or an error
const sharedContent = page.locator(".shared-schedule, .share-page"); const _sharedContent = page.locator(".shared-schedule, .share-page");
const dayBlock = page.locator(".day-block"); const dayBlock = page.locator(".day-block");
const medName = page.getByText(MED_ALICE); const medName = page.getByText(MED_ALICE);
+6 -1
View File
@@ -8,12 +8,17 @@
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed # LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
# ============================================================================= # =============================================================================
case "${LOG_LEVEL:-info}" in # Normalize: lowercase + trim whitespace
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$level" in
warn|error|fatal|silent) warn|error|fatal|silent)
export NGINX_ACCESS_LOG="off" export NGINX_ACCESS_LOG="off"
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
;; ;;
*) *)
export NGINX_ACCESS_LOG="/dev/stdout" export NGINX_ACCESS_LOG="/dev/stdout"
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
;; ;;
esac esac
+4
View File
@@ -43,5 +43,9 @@ server {
# Timeout for uploads # Timeout for uploads
proxy_read_timeout 60s; proxy_read_timeout 60s;
proxy_send_timeout 60s; proxy_send_timeout 60s;
# Prevent buffering upstream responses to temp files (images can be large)
# nginx streams directly to client instead of buffering the full response
proxy_max_temp_file_size 0;
} }
} }
+149 -110
View File
@@ -1,15 +1,16 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.10.3", "version": "1.14.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.10.3", "version": "1.14.2",
"dependencies": { "dependencies": {
"i18next": "^25.8.7", "i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.574.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -17,17 +18,18 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.15", "@biomejs/biome": "^2.4.1",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
"@types/react": "^18.3.4", "@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0", "jsdom": "^28.1.0",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.17" "vitest": "^4.0.17"
@@ -48,23 +50,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "4.1.1", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/css-calc": "^2.1.4", "@csstools/css-calc": "^3.0.0",
"@csstools/css-color-parser": "^3.1.0", "@csstools/css-color-parser": "^4.0.1",
"@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^3.0.4", "@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.4" "lru-cache": "^11.2.5"
} }
}, },
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.4", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -72,9 +74,9 @@
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "6.7.6", "version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -82,13 +84,13 @@
"bidi-js": "^1.0.3", "bidi-js": "^1.0.3",
"css-tree": "^3.1.0", "css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.4" "lru-cache": "^11.2.6"
} }
}, },
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.4", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -404,9 +406,9 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
"integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==", "integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
@@ -420,20 +422,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-arm64": "2.4.1",
"@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-darwin-x64": "2.4.1",
"@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64": "2.4.1",
"@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.4.1",
"@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64": "2.4.1",
"@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.4.1",
"@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-arm64": "2.4.1",
"@biomejs/cli-win32-x64": "2.3.15" "@biomejs/cli-win32-x64": "2.4.1"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
"integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==", "integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -448,9 +450,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
"integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==", "integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -465,9 +467,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
"integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==", "integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -482,9 +484,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
"integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==", "integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -499,9 +501,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
"integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==", "integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -516,9 +518,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
"integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==", "integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -533,9 +535,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
"integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==", "integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -550,9 +552,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.15", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
"integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==", "integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -566,10 +568,23 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "5.1.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -583,13 +598,13 @@
], ],
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=18" "node": ">=20.19.0"
} }
}, },
"node_modules/@csstools/css-calc": { "node_modules/@csstools/css-calc": {
"version": "2.1.4", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -603,17 +618,17 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=20.19.0"
}, },
"peerDependencies": { "peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^3.0.4" "@csstools/css-tokenizer": "^4.0.0"
} }
}, },
"node_modules/@csstools/css-color-parser": { "node_modules/@csstools/css-color-parser": {
"version": "3.1.0", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -627,21 +642,21 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/color-helpers": "^5.1.0", "@csstools/color-helpers": "^6.0.1",
"@csstools/css-calc": "^2.1.4" "@csstools/css-calc": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=20.19.0"
}, },
"peerDependencies": { "peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^3.0.4" "@csstools/css-tokenizer": "^4.0.0"
} }
}, },
"node_modules/@csstools/css-parser-algorithms": { "node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -655,16 +670,16 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=20.19.0"
}, },
"peerDependencies": { "peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4" "@csstools/css-tokenizer": "^4.0.0"
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.25", "version": "1.0.27",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
"integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -676,15 +691,12 @@
"url": "https://opencollective.com/csstools" "url": "https://opencollective.com/csstools"
} }
], ],
"license": "MIT-0", "license": "MIT-0"
"engines": {
"node": ">=18"
}
}, },
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
"version": "3.0.4", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -698,7 +710,7 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=20.19.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -1724,6 +1736,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2139,25 +2161,25 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cssstyle": { "node_modules/cssstyle": {
"version": "5.3.7", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^4.1.1", "@asamuzakjp/css-color": "^4.1.2",
"@csstools/css-syntax-patches-for-csstree": "^1.0.21", "@csstools/css-syntax-patches-for-csstree": "^1.0.26",
"css-tree": "^3.1.0", "css-tree": "^3.1.0",
"lru-cache": "^11.2.4" "lru-cache": "^11.2.5"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/cssstyle/node_modules/lru-cache": { "node_modules/cssstyle/node_modules/lru-cache": {
"version": "11.2.4", "version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -2438,9 +2460,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "25.8.7", "version": "25.8.10",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
"integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==", "integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -2540,16 +2562,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "28.0.0", "version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.7.6", "@asamuzakjp/dom-selector": "^6.8.1",
"@bramus/specificity": "^2.4.2",
"@exodus/bytes": "^1.11.0", "@exodus/bytes": "^1.11.0",
"cssstyle": "^5.3.7", "cssstyle": "^6.0.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
@@ -2560,7 +2583,7 @@
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",
"undici": "^7.20.0", "undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
@@ -2627,6 +2650,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.574.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz",
"integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -3281,6 +3313,13 @@
"node": ">=20.18.1" "node": ">=20.18.1"
} }
}, },
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+14 -8
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.11.1", "version": "1.14.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,15 +14,20 @@
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm", "test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
"test:e2e:headed": "playwright test --headed", "test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
"test:e2e:debug": "playwright test --debug", "test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
"test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui",
"test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed",
"test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug",
"test:e2e:report": "playwright show-report" "test:e2e:report": "playwright show-report"
}, },
"dependencies": { "dependencies": {
"i18next": "^25.8.7", "i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.574.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -30,17 +35,18 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.15", "@biomejs/biome": "^2.4.1",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
"@types/react": "^18.3.4", "@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0", "jsdom": "^28.1.0",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.17" "vitest": "^4.0.17"
+3
View File
@@ -0,0 +1,3 @@
import { buildPlaywrightConfig } from "./playwright.base.config";
export default buildPlaywrightConfig(true);
+97
View File
@@ -0,0 +1,97 @@
import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test";
export function buildPlaywrightConfig(runAllBrowsers: boolean) {
const env =
typeof globalThis === "object" && "process" in globalThis
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
: {};
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
retries: 1,
},
{
name: "chromium-data",
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
},
dependencies: ["setup"],
fullyParallel: false,
retries: 1,
},
];
if (runAllBrowsers) {
projects.push(
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
},
);
}
return defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!env.CI,
retries: env.CI ? 2 : 0,
workers: 1,
reporter: env.CI
? [["html", { outputFolder: "playwright-report" }], ["github"]]
: [["html", { outputFolder: "playwright-report" }], ["list"]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on",
viewport: { width: 1280, height: 720 },
navigationTimeout: 30000,
actionTimeout: 5000,
},
projects,
outputDir: "test-results/",
webServer: [
{
command: "cd ../backend && npm run dev",
url: "http://localhost:3000/health",
reuseExistingServer: true,
timeout: 120 * 1000,
},
{
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: true,
timeout: 120 * 1000,
},
],
});
}
+2 -152
View File
@@ -1,153 +1,3 @@
import { defineConfig, devices } from "@playwright/test"; import { buildPlaywrightConfig } from "./playwright.base.config";
/** export default buildPlaywrightConfig(false);
* Playwright E2E Testing Configuration
*
* Run E2E tests with:
* npm run test:e2e - Run tests in headless mode
* npm run test:e2e:ui - Run tests with Playwright UI
* npm run test:e2e:headed - Run tests in headed mode
*
* Before running tests, ensure both backend and frontend are running:
* docker compose -f docker-compose.dev.yml up
*
* Or run them separately:
* cd backend && npm run dev
* cd frontend && npm run dev
*/
// Base URL for the frontend dev server
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
export default defineConfig({
// Directory containing test files
testDir: "./e2e",
// Test file pattern
testMatch: "**/*.spec.ts",
// Maximum time one test can run
timeout: 30 * 1000,
// Maximum time to wait for expect assertions
expect: {
timeout: 5000,
},
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry failed tests (more retries on CI)
retries: process.env.CI ? 2 : 0,
// Opt out of parallel tests on CI
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: process.env.CI
? [["html", { outputFolder: "playwright-report" }], ["github"]]
: [["html", { outputFolder: "playwright-report" }], ["list"]],
// Shared settings for all projects
use: {
// Base URL for page.goto() calls
baseURL,
// Collect trace on first retry
trace: "on-first-retry",
// Capture screenshot on failure
screenshot: "only-on-failure",
// Record video for every test so runs can be reviewed
video: "on",
// Default viewport size
viewport: { width: 1280, height: 720 },
// Wait for network idle before considering navigation complete
navigationTimeout: 30000,
// Accept cookies and local storage
actionTimeout: 5000,
},
// Configure projects for multiple browsers
projects: [
// Setup project for authentication state
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
// Desktop Chrome — primary test browser, always runs
// Excludes data/crud tests (those run in chromium-data to avoid DB conflicts)
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
retries: 1,
},
// Desktop Firefox — runs locally and optionally in CI
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
},
// Desktop Safari — runs locally and optionally in CI
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
dependencies: ["setup"],
},
// Data tests — only Chromium, run serially to avoid DB conflicts
// These tests create/edit/delete medications and must not run concurrently
// across browsers since all share the same backend database.
{
name: "chromium-data",
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
},
dependencies: ["setup"],
fullyParallel: false,
retries: 1,
},
],
// Directory for test output files (screenshots, traces, videos)
outputDir: "test-results/",
// Web server configuration — automatically start dev servers in CI
webServer: [
{
command: "cd ../backend && npm run dev",
url: "http://localhost:3000/health",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
{
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
],
});
+3
View File
@@ -0,0 +1,3 @@
import { buildPlaywrightConfig } from "./playwright.base.config";
export default buildPlaywrightConfig(false);
+54 -30
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { import {
AboutModal, AboutModal,
Lightbox, Lightbox,
@@ -112,14 +112,13 @@ function AppRouter() {
// ============================================================================= // =============================================================================
function AppContent() { function AppContent() {
const navigate = useNavigate();
// Get shared state from AppContext // Get shared state from AppContext
const ctx = useAppContext(); const ctx = useAppContext();
const { const {
// Medications // Medications
meds, meds,
loadMeds, loadMeds,
// Settings
settings,
// Refill // Refill
showRefillModal, showRefillModal,
setShowRefillModal, setShowRefillModal,
@@ -139,7 +138,10 @@ function AppContent() {
setEditStockFullBlisters, setEditStockFullBlisters,
editStockPartialBlisterPills, editStockPartialBlisterPills,
setEditStockPartialBlisterPills, setEditStockPartialBlisterPills,
editStockLoosePills,
setEditStockLoosePills,
editStockSaving, editStockSaving,
editStockMedication,
openRefillModal, openRefillModal,
closeRefillModal, closeRefillModal,
openEditStockModal, openEditStockModal,
@@ -186,6 +188,17 @@ 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 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;
@@ -289,18 +302,25 @@ function AppContent() {
// Close tooltips on scroll/touch (for mobile) // Close tooltips on scroll/touch (for mobile)
useEffect(() => { useEffect(() => {
const closeAllTooltips = () => { const closeAllTooltips = () => {
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => { document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
el.classList.remove("tooltip-active"); el.classList.remove("tooltip-active");
}); });
}; };
const handleTooltipClick = (e: Event) => { const handleTooltipClick = (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.classList.contains("info-tooltip")) { const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
// Close other tooltips first // Close other tooltips first
closeAllTooltips(); closeAllTooltips();
// Toggle this one // Toggle this one
target.classList.add("tooltip-active"); tooltipTrigger.classList.add("tooltip-active");
// Position tooltip above the icon on mobile
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
// Place tooltip bottom edge just above the icon
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else { } else {
closeAllTooltips(); closeAllTooltips();
} }
@@ -351,9 +371,11 @@ function AppContent() {
} }
}, [meds, selectedMed, setSelectedMed]); }, [meds, selectedMed, setSelectedMed]);
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
const handleSubmitStockCorrection = async (medId: number) => { const handleSubmitStockCorrection = async (medId: number) => {
if (!selectedMed) return; if (!stockCorrectionMed) return;
await ctx.submitStockCorrection(medId, selectedMed, loadMeds); await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
}; };
// For MedDetailModal: refill without form update (not editing) // For MedDetailModal: refill without form update (not editing)
@@ -361,32 +383,30 @@ function AppContent() {
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription); await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
}; };
// Wrapper for openEditStockModal (provides selectedMed and coverage) const handleOpenMedicationEdit = () => {
const handleOpenEditStockModal = () => { if (!selectedMed) return;
if (selectedMed) { const medId = selectedMed.id;
openEditStockModal(selectedMed, coverage); setShowImageLightbox(false);
} setShowRefillModal(false);
setShowEditStockModal(false);
setSelectedMed(null);
navigate(`/medications?editMedId=${medId}`);
}; };
function openProfile() { const handleOpenEditStockFromDetail = () => {
if (!selectedMed) return;
openEditStockModal(selectedMed, coverage);
};
const openProfile = useCallback(() => {
setShowProfile(true); setShowProfile(true);
window.history.pushState({ modal: "profile" }, ""); window.history.pushState({ modal: "profile" }, "");
} }, []);
function closeProfile() {
if (showProfile) {
window.history.back();
}
}
function openAbout() { const openAbout = useCallback(() => {
setShowAbout(true); setShowAbout(true);
window.history.pushState({ modal: "about" }, ""); window.history.pushState({ modal: "about" }, "");
} }, []);
function closeAbout() {
if (showAbout) {
window.history.back();
}
}
return ( return (
<main className="page"> <main className="page">
@@ -415,18 +435,20 @@ function AppContent() {
{/* Medication Detail Modal */} {/* Medication Detail Modal */}
<MedDetailModal <MedDetailModal
selectedMed={selectedMed} selectedMed={stockCorrectionMed}
coverage={coverage} coverage={coverage}
settings={stockThresholds} settings={stockThresholds}
showImageLightbox={showImageLightbox} showImageLightbox={showImageLightbox}
showRefillModal={showRefillModal} showRefillModal={showRefillModal}
showEditStockModal={showEditStockModal} showEditStockModal={showEditStockModal}
editStockOnly={showEditStockModal && !selectedMed}
onClose={closeMedDetail} onClose={closeMedDetail}
onOpenImageLightbox={openImageLightbox} onOpenImageLightbox={openImageLightbox}
onCloseImageLightbox={closeImageLightbox} onCloseImageLightbox={closeImageLightbox}
onOpenRefillModal={openRefillModal} onOpenRefillModal={openRefillModal}
onCloseRefillModal={closeRefillModal} onCloseRefillModal={closeRefillModal}
onOpenEditStockModal={handleOpenEditStockModal} onOpenMedicationEdit={handleOpenMedicationEdit}
onOpenEditStockModal={handleOpenEditStockFromDetail}
onCloseEditStockModal={closeEditStockModal} onCloseEditStockModal={closeEditStockModal}
refillPacks={refillPacks} refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks} onRefillPacksChange={setRefillPacks}
@@ -443,6 +465,8 @@ function AppContent() {
onEditStockFullBlistersChange={setEditStockFullBlisters} onEditStockFullBlistersChange={setEditStockFullBlisters}
editStockPartialBlisterPills={editStockPartialBlisterPills} editStockPartialBlisterPills={editStockPartialBlisterPills}
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills} onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
editStockLoosePills={editStockLoosePills}
onEditStockLoosePillsChange={setEditStockLoosePills}
editStockSaving={editStockSaving} editStockSaving={editStockSaving}
onSubmitStockCorrection={handleSubmitStockCorrection} onSubmitStockCorrection={handleSubmitStockCorrection}
/> />
+12 -2
View File
@@ -51,8 +51,18 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}> className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content about-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button className="modal-close" onClick={onClose}> <button className="modal-close" onClick={onClose}>
× ×
</button> </button>
-1
View File
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useUnsavedChanges } from "../context"; import { useUnsavedChanges } from "../context";
import type { ThemePreference } from "../hooks";
import { useTheme } from "../hooks"; import { useTheme } from "../hooks";
import { useAuth } from "./Auth"; import { useAuth } from "./Auth";
+4 -3
View File
@@ -1,3 +1,4 @@
/* 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 { log } from "../utils/logger"; import { log } from "../utils/logger";
@@ -70,7 +71,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,7 +90,7 @@ 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;
@@ -756,7 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="profile-actions"> <div className="profile-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}> <button type="button" className="btn btn-ghost" onClick={onClose}>
{t("common.cancel", "Cancel")} {t("common.close", "Close")}
</button> </button>
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}> <button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")} {loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
+14 -3
View File
@@ -12,7 +12,7 @@ export interface ConfirmModalProps {
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
isLoading?: boolean; isLoading?: boolean;
confirmVariant?: "primary" | "danger" | "success"; confirmVariant?: "primary" | "danger" | "success" | "warning";
overlayClassName?: string; overlayClassName?: string;
} }
@@ -39,8 +39,19 @@ export function ConfirmModal({
}, [onCancel]); }, [onCancel]);
return ( return (
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}> <div
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}> className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
onClick={onCancel}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div
className="modal-content confirm-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
style={{ maxWidth: "450px" }}
>
<button className="modal-close" onClick={onCancel}> <button className="modal-close" onClick={onCancel}>
× ×
</button> </button>
+7 -1
View File
@@ -28,7 +28,13 @@ export function DateInput({ value, placeholder, className, ...rest }: DateInputP
}, []); }, []);
return ( return (
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}> <div
className={`date-input-wrapper ${className ?? ""}`}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
>
<span className="date-input-display" aria-hidden="true"> <span className="date-input-display" aria-hidden="true">
{displayValue || placeholder || ""} {displayValue || placeholder || ""}
</span> </span>
+7 -1
View File
@@ -29,7 +29,13 @@ export function DateTimeInput({ value, placeholder, className, ...rest }: DateTi
}, []); }, []);
return ( return (
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}> <div
className={`date-input-wrapper ${className ?? ""}`}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleClick();
}}
>
<span className="date-input-display" aria-hidden="true"> <span className="date-input-display" aria-hidden="true">
{displayValue || placeholder || ""} {displayValue || placeholder || ""}
</span> </span>
+14 -3
View File
@@ -13,8 +13,19 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}> className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
style={{ maxWidth: "450px" }}
>
<button className="modal-close" onClick={onClose}> <button className="modal-close" onClick={onClose}>
× ×
</button> </button>
@@ -53,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
</div> </div>
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}> <div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
<button type="button" className="ghost" onClick={onClose}> <button type="button" className="ghost" onClick={onClose}>
{t("exportImport.cancelButton")} {t("common.close")}
</button> </button>
</div> </div>
</div> </div>
+17 -2
View File
@@ -19,12 +19,27 @@ 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();
onClose();
}
}}
>
<div className="lightbox-container"> <div className="lightbox-container">
<button className="lightbox-close" onClick={onClose}> <button className="lightbox-close" onClick={onClose}>
× ×
</button> </button>
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} /> <img
src={src}
alt={alt}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+409 -240
View File
@@ -2,7 +2,10 @@
* MobileEditModal - Full-screen edit form for medications (mobile-optimized) * MobileEditModal - Full-screen edit form for medications (mobile-optimized)
* Handles new medication creation and editing existing medications * Handles new medication creation and editing existing medications
*/ */
import { useEffect } from "react";
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import 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";
@@ -17,6 +20,9 @@ const FIELD_LIMITS = {
notes: { max: 1000 }, notes: { max: 1000 },
}; };
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
export interface MobileEditModalProps { export interface MobileEditModalProps {
show: boolean; show: boolean;
editingId: number | null; editingId: number | null;
@@ -46,15 +52,6 @@ export interface MobileEditModalProps {
onRemoveIntake: (idx: number) => void; onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields // Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void; onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
// Refill state (for edit mode)
refillPacks: number;
onRefillPacksChange: (value: number) => void;
refillLoose: number;
onRefillLooseChange: (value: number) => void;
usePrescriptionRefill: boolean;
onUsePrescriptionRefillChange: (value: boolean) => void;
refillSaving: boolean;
onSubmitRefill: (medId: number) => Promise<void>;
// Image handling // Image handling
meds: Medication[]; meds: Medication[];
onUploadMedImage: (medId: number, file: File) => Promise<void>; onUploadMedImage: (medId: number, file: File) => Promise<void>;
@@ -74,8 +71,7 @@ function deriveTotalFromForm(form: FormState) {
const packCount = Number(form.packCount) || 0; const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1; const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0; return deriveTotal(packCount, blistersPerPack, pillsPerBlister, 0);
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
} }
export function MobileEditModal({ export function MobileEditModal({
@@ -96,21 +92,13 @@ export function MobileEditModal({
onAddTakenByPerson, onAddTakenByPerson,
onRemoveTakenByPerson, onRemoveTakenByPerson,
onTakenByKeyDown, onTakenByKeyDown,
onSetBlisterValue, _onSetBlisterValue,
onAddBlister, _onAddBlister,
onRemoveBlister, _onRemoveBlister,
onSetIntakeValue, onSetIntakeValue,
onAddIntake, onAddIntake,
onRemoveIntake, onRemoveIntake,
onHandleValueChange, onHandleValueChange,
refillPacks,
onRefillPacksChange,
refillLoose,
onRefillLooseChange,
usePrescriptionRefill,
onUsePrescriptionRefillChange,
refillSaving,
onSubmitRefill,
meds, meds,
onUploadMedImage, onUploadMedImage,
onDeleteMedImage, onDeleteMedImage,
@@ -119,6 +107,20 @@ export function MobileEditModal({
onSaveMedication, onSaveMedication,
}: MobileEditModalProps) { }: MobileEditModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<MobileTab>("general");
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
const tabStripRef = useRef<HTMLDivElement | null>(null);
const tabViewportRef = useRef<HTMLDivElement | null>(null);
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
const swipeAxisRef = useRef<"x" | "y" | null>(null);
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
const activeTabIndexRef = useRef(0);
// Reset tab when modal opens
useEffect(() => {
if (show) setActiveTab("general");
}, [show]);
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
@@ -132,21 +134,195 @@ export function MobileEditModal({
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [show, onClose]); }, [show, onClose]);
// Lock background scroll while modal is open.
useEffect(() => {
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
const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab);
activeTabIndexRef.current = activeTabIndex;
// Auto-scroll tab strip to keep active tab visible
useEffect(() => {
const strip = tabStripRef.current;
if (!strip) return;
const btn = strip.children[activeTabIndex] as HTMLElement | undefined;
if (btn) btn.scrollIntoView?.({ behavior: "smooth", inline: "nearest", block: "nearest" });
}, [activeTabIndex]);
// Non-passive touch listeners for reliable horizontal swipe detection.
// React's onTouchMove is passive, so e.preventDefault() is a no-op there.
// With native { passive: false } we can block the browser's vertical scroll
// when a horizontal swipe is detected, making tab swiping reliable.
useEffect(() => {
const fieldset = fieldsetRef.current;
if (!show || !fieldset) return;
const AXIS_LOCK_THRESHOLD = 6;
function resetSwipe() {
swipeStartRef.current = null;
swipeAxisRef.current = null;
setIsHorizontalSwiping(false);
setSwipeDeltaX(0);
}
function onTouchStart(e: TouchEvent) {
if (e.touches.length !== 1) {
resetSwipe();
return;
}
const touch = e.touches[0];
swipeStartRef.current = { x: touch.clientX, y: touch.clientY };
swipeAxisRef.current = null;
setIsHorizontalSwiping(false);
setSwipeDeltaX(0);
}
function onTouchMove(e: TouchEvent) {
if (!swipeStartRef.current || e.touches.length !== 1) return;
const touch = e.touches[0];
const dx = touch.clientX - swipeStartRef.current.x;
const dy = touch.clientY - swipeStartRef.current.y;
const ax = Math.abs(dx);
const ay = Math.abs(dy);
if (!swipeAxisRef.current) {
if (ax < AXIS_LOCK_THRESHOLD && ay < AXIS_LOCK_THRESHOLD) return;
swipeAxisRef.current = ax >= ay ? "x" : "y";
}
if (swipeAxisRef.current === "y") return;
// Horizontal swipe — block native vertical scroll
e.preventDefault();
setIsHorizontalSwiping(true);
let nextDelta = dx;
const idx = activeTabIndexRef.current;
if ((idx === 0 && nextDelta > 0) || (idx === MOBILE_TAB_ORDER.length - 1 && nextDelta < 0)) {
nextDelta *= 0.35;
}
setSwipeDeltaX(nextDelta);
}
function onTouchEnd(e: TouchEvent) {
if (!swipeStartRef.current || e.changedTouches.length !== 1) {
resetSwipe();
return;
}
if (swipeAxisRef.current === "x") {
const touch = e.changedTouches[0];
const dx = touch.clientX - swipeStartRef.current.x;
const minSwipe = Math.max(36, (tabViewportRef.current?.clientWidth ?? 360) * 0.1);
if (Math.abs(dx) >= minSwipe) {
const direction = dx < 0 ? 1 : -1;
const idx = activeTabIndexRef.current;
const next = Math.min(Math.max(idx + direction, 0), MOBILE_TAB_ORDER.length - 1);
if (next !== idx) {
setActiveTab(MOBILE_TAB_ORDER[next]);
}
}
}
resetSwipe();
}
fieldset.addEventListener("touchstart", onTouchStart, { passive: true });
fieldset.addEventListener("touchmove", onTouchMove, { passive: false });
fieldset.addEventListener("touchend", onTouchEnd, { passive: true });
fieldset.addEventListener("touchcancel", resetSwipe, { passive: true });
return () => {
fieldset.removeEventListener("touchstart", onTouchStart);
fieldset.removeEventListener("touchmove", onTouchMove);
fieldset.removeEventListener("touchend", onTouchEnd);
fieldset.removeEventListener("touchcancel", resetSwipe);
};
}, [show]);
if (!show) return null; if (!show) return null;
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null; const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
const mobileTitle = (() => {
if (!editingId) return t("form.newEntry");
if (readOnlyMode) return t("form.viewEntry");
const medicationName = currentMed?.name?.trim() || form.name.trim();
if (!medicationName) return t("form.editEntry");
return t("form.editEntryWithName", { name: medicationName });
})();
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}> className="modal-overlay mobile-edit-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content edit-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="edit-modal-header"> <div className="edit-modal-header">
<button type="button" className="ghost small btn-nav" onClick={onClose}> <button type="button" className="ghost small btn-nav" onClick={onClose}>
{t("common.back")} {t("common.back")}
</button> </button>
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2> <h2>{mobileTitle}</h2>
</div> </div>
<form <form
className="form-grid mobile-edit-form" className="form-grid mobile-edit-form"
autoComplete="off"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
onSubmit={(e) => { onSubmit={(e) => {
// Check native HTML5 validation first // Check native HTML5 validation first
const formElement = e.currentTarget; const formElement = e.currentTarget;
@@ -159,7 +335,55 @@ export function MobileEditModal({
onSaveMedication(e); onSaveMedication(e);
}} }}
> >
<fieldset className="readonly-fieldset" disabled={readOnlyMode}> <div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")} ref={tabStripRef}>
<button
type="button"
role="tab"
aria-selected={activeTab === "general"}
className={`form-tab${activeTab === "general" ? " active" : ""}`}
onClick={() => setActiveTab("general")}
>
{t("form.sections.general")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "stock"}
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
onClick={() => setActiveTab("stock")}
>
{t("form.sections.stock")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "schedule"}
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
onClick={() => setActiveTab("schedule")}
>
{t("form.sections.schedule")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "prescription"}
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
onClick={() => setActiveTab("prescription")}
>
{t("form.sections.prescription")}
</button>
</div>
<fieldset
ref={fieldsetRef}
className={`readonly-fieldset${isHorizontalSwiping ? " swiping-horizontal" : ""}`}
disabled={readOnlyMode}
>
<div className="mobile-tab-viewport" ref={tabViewportRef}>
<div
className={`mobile-tab-track${isHorizontalSwiping ? " is-swiping" : ""}`}
style={{ transform: `translateX(calc(${-activeTabIndex * 100}% + ${swipeDeltaX}px))` }}
>
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
<div className="full form-category"> <div className="full form-category">
<h4 className="form-category-title">{t("form.sections.general")}</h4> <h4 className="form-category-title">{t("form.sections.general")}</h4>
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}> <label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
@@ -189,7 +413,20 @@ export function MobileEditModal({
value={form.medicationStartDate} value={form.medicationStartDate}
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)} onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
/> />
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>} {!readOnlyMode && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label> </label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}> <label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")} {t("form.takenBy")}
@@ -210,7 +447,9 @@ export function MobileEditModal({
if (takenByInput.trim()) onAddTakenByPerson(takenByInput); if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
}} }}
placeholder={ placeholder={
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson") form.takenBy.length === 0
? t("form.placeholders.takenBy")
: t("form.placeholders.addPerson")
} }
maxLength={FIELD_LIMITS.takenBy.max} maxLength={FIELD_LIMITS.takenBy.max}
list="takenby-suggestions-modal" list="takenby-suggestions-modal"
@@ -225,19 +464,35 @@ export function MobileEditModal({
</div> </div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>} {fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label> </label>
<label className="full">
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
</div> </div>
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => onDeleteMedImage(editingId)}
aria-label={t("form.removeImage")}
data-tooltip={t("form.removeImage")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
)}
</div>
)}
</div>
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
<div className="full form-category"> <div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4> <h4 className="form-category-title">{t("form.sections.stock")}</h4>
{form.packageType === "blister" ? ( {form.packageType === "blister" ? (
@@ -273,14 +528,8 @@ export function MobileEditModal({
/> />
</label> </label>
<label> <label>
{t("form.loosePills")} {t("form.total")}
<input <div className="static-value">{deriveTotalFromForm(form)}</div>
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label> </label>
</> </>
) : ( ) : (
@@ -307,12 +556,16 @@ export function MobileEditModal({
</label> </label>
</> </>
)} )}
<div className="full"> {form.packageType === "bottle" && (
<div className="full stock-total-row">
<div className="stock-total-field">
<p className="sub"> <p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "} <strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")} {deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p> </p>
</div> </div>
</div>
)}
<label className="full"> <label className="full">
{t("form.pillWeight")} ({form.doseUnit}) {t("form.pillWeight")} ({form.doseUnit})
<div className="dose-input-group"> <div className="dose-input-group">
@@ -360,26 +613,128 @@ export function MobileEditModal({
}} }}
/> />
{form.notes.length > 0 && ( {form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}> <span
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })} {t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span> </span>
)} )}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>} {fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label> </label>
</div> </div>
</div>
<div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
<div className="full form-category intake-section">
<div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
{!readOnlyMode && (
<button
type="button"
className="ghost add-blister icon-only tooltip-trigger"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
aria-label={t("form.blisters.addIntake")}
data-tooltip={t("form.blisters.addIntake")}
>
<Plus size={18} aria-hidden="true" />
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
className="blister-row"
>
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={intake.every}
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.takenBy.length === 0 ? null : (
<label className="compact full-row taken-by-field">
<span>{t("form.blisters.takenByIntake")}</span>
<select
value={intake.takenBy}
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button
type="button"
className="danger remove-blister-btn icon-only tooltip-trigger"
onClick={() => onRemoveIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button>
)}
</div>
))}
</div>
</div>
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
<div className="full form-category"> <div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4> <h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full"> <label className="full">
{t("prescription.enabled")} {t("prescription.enabled")}
<label className="toggle-switch small"> <span className="toggle-switch small">
<input <input
type="checkbox" type="checkbox"
checked={form.prescriptionEnabled} checked={form.prescriptionEnabled}
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)} onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
/> />
<span className="toggle-slider"></span> <span className="toggle-slider"></span>
</label> </span>
</label> </label>
{form.prescriptionEnabled && ( {form.prescriptionEnabled && (
<> <>
@@ -423,199 +778,13 @@ export function MobileEditModal({
</> </>
)} )}
</div> </div>
{!readOnlyMode && (
<div className="full form-category refill-section">
<h4 className="form-category-title">{t("refill.title")}</h4>
{editingId ? (
<>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label className="full">
{t("refill.pillsToAdd")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<div className="refill-submit-row full">
<button
type="button"
className="success"
onClick={() => onSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div> </div>
{form.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
</span>
</div> </div>
)}
</>
) : (
<p className="refill-unavailable">
{t("refill.saveFirst", "Save medication first to enable refill")}
</p>
)}
</div>
)}
{editingId && (
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{currentMed?.imageUrl ? (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
/>
)}
</div>
)}
<div className="full form-category intake-section">
<div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
{!readOnlyMode && (
<button
type="button"
className="ghost add-blister"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
)}
</div>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
value={intake.usage}
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/>
</label>
<label className="compact">
<span>{t("form.blisters.everyDays")}</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={intake.every}
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.startDate")}</span>
<DateInput
value={intake.startDate}
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/>
</label>
<label className="compact time-label">
<span>{t("form.blisters.startTime")}</span>
<input
type="time"
value={intake.startTime}
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
{form.takenBy.length === 0 ? null : (
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">🔔</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{!readOnlyMode && form.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")}
</button>
)}
</div>
))}
</div> </div>
</fieldset> </fieldset>
<div className="modal-footer"> <div className="modal-footer">
<button type="button" className="ghost" onClick={onClose}> <button type="button" className="ghost" onClick={onClose}>
{readOnlyMode ? t("common.close") : t("common.cancel")} {readOnlyMode || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
</button> </button>
{!readOnlyMode && ( {!readOnlyMode && (
<button <button
+12 -2
View File
@@ -9,8 +9,18 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}> className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content profile-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button className="modal-close" onClick={onClose}> <button className="modal-close" onClick={onClose}>
× ×
</button> </button>
+668
View File
@@ -0,0 +1,668 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Medication } from "../types";
import { getPackageSize } from "../types";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
interface ReportModalProps {
isOpen: boolean;
onClose: () => void;
medications: Medication[];
}
type ReportData = Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
}
>;
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [format, setFormat] = useState<ReportFormat>("pdf");
const [generating, setGenerating] = useState(false);
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
// Collect all unique "taken by" people across all medications
const allPeople = useMemo(() => {
const people = new Set<string>();
for (const med of medications) {
if (med.takenBy) {
for (const p of med.takenBy) people.add(p);
}
}
return Array.from(people).sort();
}, [medications]);
// Filtered medications based on takenBy filter
const filteredMeds = useMemo(() => {
if (takenByFilter.size === 0) return medications;
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
}, [medications, takenByFilter]);
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
const obsoleteMeds = useMemo(() => filteredMeds.filter((m) => m.isObsolete), [filteredMeds]);
const togglePerson = useCallback((person: string) => {
setTakenByFilter((prev) => {
const next = new Set(prev);
if (next.has(person)) next.delete(person);
else next.add(person);
return next;
});
}, []);
const selectAllPeople = useCallback(() => {
setTakenByFilter(new Set());
}, []);
// Reset selection when modal opens or filter changes
useEffect(() => {
if (isOpen) {
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
}
}, [isOpen, filteredMeds]);
// Reset everything when modal opens
useEffect(() => {
if (isOpen) {
setTakenByFilter(new Set());
setFormat("pdf");
setGenerating(false);
}
}, [isOpen]);
const toggleMed = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
}, [filteredMeds]);
const deselectAll = useCallback(() => {
setSelectedIds(new Set());
}, []);
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
async function handleGenerate() {
if (selectedIds.size === 0) return;
setGenerating(true);
try {
// Fetch report data from backend
const res = await fetch("/api/medications/report-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }),
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch report data");
const reportData = (await res.json()) as ReportData;
if (format === "pdf") {
const imageMap = await fetchMedImages(selectedMeds);
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
} else {
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
downloadFile(content, format);
}
onClose();
} catch {
// Stay open on error so user can retry
} finally {
setGenerating(false);
}
}
if (!isOpen) return null;
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content report-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button className="modal-close" onClick={onClose}>
×
</button>
<h2 className="report-modal-title">{t("report.title")}</h2>
<p className="report-modal-desc">{t("report.description")}</p>
{/* Person filter */}
{allPeople.length > 1 && (
<div className="report-person-filter">
<h4>{t("report.filterByPerson")}</h4>
<div className="report-format-options">
<label className={`report-format-option${takenByFilter.size === 0 ? " selected" : ""}`}>
<input type="checkbox" checked={takenByFilter.size === 0} onChange={selectAllPeople} />
<span>{t("report.allPeople")}</span>
</label>
{allPeople.map((person) => (
<label key={person} className={`report-format-option${takenByFilter.has(person) ? " selected" : ""}`}>
<input type="checkbox" checked={takenByFilter.has(person)} onChange={() => togglePerson(person)} />
<span>{person}</span>
</label>
))}
</div>
</div>
)}
{/* Medication selection */}
<div className="report-selection">
<div className="report-selection-header">
<button
type="button"
className="ghost small"
onClick={selectedIds.size === filteredMeds.length ? deselectAll : selectAll}
>
{selectedIds.size === filteredMeds.length ? t("report.deselectAll") : t("report.selectAll")}
</button>
<span className="report-selection-count">
{selectedIds.size} / {filteredMeds.length}
</span>
</div>
{activeMeds.length > 0 && (
<div className="report-group">
<h4 className="report-group-title">{t("report.activeMeds")}</h4>
<div className="report-med-list">
{activeMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
</div>
</div>
)}
{obsoleteMeds.length > 0 && (
<div className="report-group">
<h4 className="report-group-title">{t("report.obsoleteMeds")}</h4>
<div className="report-med-list">
{obsoleteMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name obsolete-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
</div>
</div>
)}
</div>
{/* Format selection */}
<div className="report-format">
<h4>{t("report.format")}</h4>
<div className="report-format-options">
<label className={`report-format-option${format === "pdf" ? " selected" : ""}`}>
<input
type="radio"
name="format"
value="pdf"
checked={format === "pdf"}
onChange={() => setFormat("pdf")}
/>
<span>{t("report.formatPdf")}</span>
</label>
<label className={`report-format-option${format === "txt" ? " selected" : ""}`}>
<input
type="radio"
name="format"
value="txt"
checked={format === "txt"}
onChange={() => setFormat("txt")}
/>
<span>{t("report.formatTxt")}</span>
</label>
<label className={`report-format-option${format === "md" ? " selected" : ""}`}>
<input type="radio" name="format" value="md" checked={format === "md"} onChange={() => setFormat("md")} />
<span>{t("report.formatMd")}</span>
</label>
</div>
</div>
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
{t("common.close")}
</button>
<button
type="button"
className="primary"
onClick={handleGenerate}
disabled={selectedIds.size === 0 || generating}
>
{generating ? t("report.generating") : t("report.generate")}
</button>
</div>
</div>
</div>
);
}
// ─── Report generation helpers ───
type TFn = (key: string, opts?: Record<string, unknown>) => string;
function fmtDate(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return "-";
return `${m[3]}.${m[2]}.${m[1]}`;
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return `${fmtDate(iso)}`;
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
}
function generateTextReport(
meds: Medication[],
reportData: ReportData,
fmt: "txt" | "md",
t: TFn,
personFilter: string[] | null
): string {
const lines: string[] = [];
const sep = fmt === "md" ? "---" : "═".repeat(60);
const h1 = (s: string) => (fmt === "md" ? `# ${s}` : s);
const h2 = (s: string) => (fmt === "md" ? `## ${s}` : s);
const h3 = (s: string) => (fmt === "md" ? `### ${s}` : ` ${s}`);
const bold = (s: string) => (fmt === "md" ? `**${s}**` : s);
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
lines.push("");
for (const med of meds) {
lines.push(sep);
lines.push("");
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
lines.push(h2(title));
lines.push("");
// General
lines.push(h3(t("report.docGeneral")));
lines.push(item(t("report.docCommercialName"), med.name));
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
lines.push(
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
);
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
lines.push("");
// Package / Stock
lines.push(h3(t("report.docPackage")));
lines.push(
item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))
);
if (med.packageType === "blister") {
lines.push(item(t("report.docPacks"), String(med.packCount)));
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
} else {
lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets)));
}
lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`));
if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push("");
// Intake Schedule
const allIntakes = med.intakes ?? med.blisters;
const intakes = personFilter
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
: allIntakes;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`;
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
}
// Prescription
if (med.prescriptionEnabled) {
lines.push(h3(t("report.docPrescription")));
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
if (med.prescriptionExpiryDate)
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
lines.push("");
}
// Dose tracking data
const data = reportData[med.id];
if (data) {
lines.push(h3(t("report.docIntakeHistory")));
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
if (data.automaticDosesTaken > 0) {
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
}
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
} else {
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
}
lines.push("");
// Refill history
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
lines.push("");
}
}
}
lines.push(sep);
return lines.join("\n");
}
function downloadFile(content: string, format: "txt" | "md") {
const mimeType = format === "md" ? "text/markdown" : "text/plain";
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const dateStr = new Date().toISOString().slice(0, 10);
a.href = url;
a.download = `medassist-report-${dateStr}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
type ImageMap = Record<number, string>;
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
const map: ImageMap = {};
const fetches = meds
.filter((m) => m.imageUrl)
.map(async (m) => {
try {
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
if (!res.ok) return;
const blob = await res.blob();
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
map[m.id] = dataUrl;
} catch {
// Skip image on error
}
});
await Promise.all(fetches);
return map;
}
function openPrintView(
meds: Medication[],
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
) {
const w = window.open("", "_blank");
if (!w) return;
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
w.document.write(html);
w.document.close();
w.onload = () => setTimeout(() => w.print(), 300);
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function buildPrintHtml(
meds: Medication[],
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
): string {
const sections: string[] = [];
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const title = med.isObsolete
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(med.name);
let s = `<div class="med-section">`;
const imgDataUrl = imageMap[med.id];
// Title with generic name subtitle
s += `<h2>${title}</h2>`;
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
// Build general info table rows
const generalRows: string[] = [];
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.genericName)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
);
if (med.takenBy?.length)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docTakenBy"))}</td><td>${escHtml(med.takenBy.join(", "))}</td></tr>`
);
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStatus"))}</td><td>${escHtml(med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))}</td></tr>`
);
if (med.medicationStartDate)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
);
if (med.isObsolete && med.obsoleteAt)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
);
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
if (imgDataUrl) {
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(med.name)}" /><div class="med-overview-info">${generalTable}</div></div>`;
} else {
s += generalTable;
}
// Package / Stock
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}</td></tr>`;
if (med.packageType === "blister") {
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
if (med.looseTablets > 0)
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
} else {
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
}
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${getPackageSize(med)} ${escHtml(t("common.pills"))}</td></tr>`;
if (med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
// Intake Schedule
const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
: allPrintIntakes;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`;
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
if ("takenBy" in intake && intake.takenBy)
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
}
// Prescription
if (med.prescriptionEnabled) {
s += `<h3>${escHtml(t("report.docPrescription"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`;
}
// Intake history
if (data) {
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
if (data.automaticDosesTaken > 0) {
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
}
if (data.dosesDismissed > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
}
// Refill history
if (data.refills.length > 0) {
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
}
}
s += `</div>`;
sections.push(s);
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${escHtml(t("report.docTitle"))}</title>
<style>
@media print {
body { margin: 0; padding: 1rem; }
.no-print { display: none !important; }
.med-section:last-child { margin-bottom: 0; padding-bottom: 0; }
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1e293b;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.5;
}
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #64748b; margin-bottom: 1rem; }
.med-section { margin-bottom: 1.5rem; padding-bottom: 1rem; }
.med-section:last-child { }
h2 { font-size: 1.25rem; color: #0f172a; margin: 0; }
.generic-subtitle { margin: 0.1rem 0 0.5rem; font-size: 0.9rem; font-style: italic; color: #64748b; }
h2 + .med-overview { margin-top: 0.5rem; }
.med-overview { display: flex; gap: 1.25rem; align-items: flex-start; }
.med-overview-info { flex: 1; min-width: 0; }
.med-overview-info h3 { margin-top: 0; }
.med-img { width: 220px; height: 220px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
h3 { font-size: 0.9rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 0.5rem; }
td { padding: 0.25rem 0.5rem; }
td.label { font-weight: 500; color: #475569; width: 40%; }
ul { margin: 0.25rem 0; padding-left: 1.5rem; }
li { margin: 0.25rem 0; }
.obsolete-badge { font-size: 0.75rem; background: #fef3c7; color: #92400e; padding: 0.125rem 0.5rem; border-radius: 4px; vertical-align: middle; }
.no-data { color: #94a3b8; font-style: italic; }
.print-hint { text-align: center; padding: 1rem; background: #f0f9ff; border-radius: 8px; color: #0369a1; margin-bottom: 1.5rem; }
</style>
</head>
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
${sections.join("\n")}
</body>
</html>`;
}
export default ReportModal;
+60 -16
View File
@@ -2,6 +2,8 @@
* ShareDialog - Modal for generating share links for medication schedules * ShareDialog - Modal for generating share links for medication schedules
* Allows sharing schedule view for a specific person * Allows sharing schedule view for a specific person
*/ */
import { Check, Copy, Link2, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface ShareDialogProps { export interface ShareDialogProps {
@@ -38,26 +40,51 @@ export function ShareDialog({
onCopyShareLink, onCopyShareLink,
}: ShareDialogProps) { }: ShareDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
if (!show) return null; if (!show) return null;
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}> className="modal-overlay"
<button className="modal-close" onClick={onClose}> onClick={onClose}
× onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content share-dialog-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="modal-close tooltip-trigger"
onClick={onClose}
aria-label={closeLabel}
data-tooltip={closeLabel}
>
<X size={18} aria-hidden="true" />
</button> </button>
<div className="share-dialog-header"> <div className="share-dialog-header">
<h2>🔗 {t("share.title")}</h2> <h2>
<Link2 size={18} aria-hidden="true" /> {t("share.title")}
</h2>
<p className="share-dialog-description">{t("share.description")}</p> <p className="share-dialog-description">{t("share.description")}</p>
</div> </div>
{sharePeople.length === 0 ? ( {(() => {
if (sharePeople.length === 0) {
return (
<div className="share-dialog-empty"> <div className="share-dialog-empty">
<p>{t("share.noPeople")}</p> <p>{t("share.noPeople")}</p>
</div> </div>
) : shareLink ? ( );
}
if (shareLink) {
return (
<div className="share-dialog-result"> <div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p> <p className="share-success">{t("share.linkGenerated")}</p>
<div className="share-link-box"> <div className="share-link-box">
@@ -68,8 +95,14 @@ export function ShareDialog({
className="share-link-input" className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()} onClick={(e) => (e.target as HTMLInputElement).select()}
/> />
<button className="btn-copy" onClick={onCopyShareLink}> <button
{shareCopied ? "✓" : "📋"} type="button"
className="btn-copy icon-only tooltip-trigger"
onClick={onCopyShareLink}
aria-label={copyLabel}
data-tooltip={copyLabel}
>
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
</button> </button>
</div> </div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>} {shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
@@ -86,11 +119,17 @@ export function ShareDialog({
<button onClick={onClose}>{t("common.close")}</button> <button onClick={onClose}>{t("common.close")}</button>
</div> </div>
</div> </div>
) : ( );
}
return (
<div className="share-dialog-form"> <div className="share-dialog-form">
<div className="form-group"> <div className="form-group">
<label>{t("share.selectPerson")}</label> <label htmlFor="share-person-select">{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}> <select
id="share-person-select"
value={shareSelectedPerson}
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
>
{sharePeople.map((person) => ( {sharePeople.map((person) => (
<option key={person} value={person}> <option key={person} value={person}>
{person} {person}
@@ -100,8 +139,12 @@ export function ShareDialog({
</div> </div>
<div className="form-group"> <div className="form-group">
<label>{t("share.selectPeriod")}</label> <label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}> <select
id="share-period-select"
value={shareSelectedDays}
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
>
<option value={30}>{t("dashboard.schedules.1month")}</option> <option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option> <option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option> <option value={180}>{t("dashboard.schedules.6months")}</option>
@@ -110,14 +153,15 @@ export function ShareDialog({
<div className="share-dialog-footer"> <div className="share-dialog-footer">
<button className="ghost" onClick={onClose}> <button className="ghost" onClick={onClose}>
{t("common.cancel")} {t("common.close")}
</button> </button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}> <button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t("share.generating") : t("share.generateLink")} {shareGenerating ? t("share.generating") : t("share.generateLink")}
</button> </button>
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
</div> </div>
); );
+97 -26
View File
@@ -1,6 +1,8 @@
// ============================================================================= // =============================================================================
// 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";
@@ -209,7 +211,7 @@ export function SharedSchedule() {
// Get dose ID - for per-intake takenBy, the ID already has the person suffix // Get dose ID - for per-intake takenBy, the ID already has the person suffix
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id // This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
function getDoseId(doseId: string, _person: string | null): string { function _getDoseId(doseId: string, _person: string | null): string {
// The dose.id already includes the person suffix if there's a per-intake takenBy // The dose.id already includes the person suffix if there's a per-intake takenBy
return doseId; return doseId;
} }
@@ -479,7 +481,8 @@ export function SharedSchedule() {
const intake = intakes[blisterIdx]; const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy; const intakePerson = intake?.takenBy;
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null]; const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
let timeBasedConsumed = 0; let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0; let lastAutoConsumedDateMs = 0;
@@ -579,11 +582,13 @@ export function SharedSchedule() {
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds); const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
return status.className; return status.className;
}); });
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
return statuses.includes("danger") ? "danger" : fallbackStatus;
} }
// Whether to show stock status indicators on the shared schedule // Whether to show stock status indicators on the shared schedule
const showStock = data?.shareStockStatus !== false; const showStock = data?.shareStockStatus !== false;
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed) // Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
function isDoseIdDone(doseId: string): boolean { function isDoseIdDone(doseId: string): boolean {
@@ -606,7 +611,7 @@ export function SharedSchedule() {
const missedPastDoseIds = useMemo(() => { const missedPastDoseIds = useMemo(() => {
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id))); const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return allPastDoseIds.filter((id) => !isDoseIdDone(id)); return allPastDoseIds.filter((id) => !isDoseIdDone(id));
}, [pastDays, takenDoses, dismissedDoses, data]); }, [pastDays, isDoseIdDone]);
if (loading) { if (loading) {
return ( return (
@@ -714,14 +719,20 @@ export function SharedSchedule() {
</div> </div>
</div> </div>
</div> </div>
<p className="shared-schedule-period"> {!showOnlyToday &&
{t("share.period")}:{" "} (() => {
{data.scheduleDays === 30 const periodLabel =
data.scheduleDays === 30
? t("dashboard.schedules.1month") ? t("dashboard.schedules.1month")
: data.scheduleDays === 90 : data.scheduleDays === 90
? t("dashboard.schedules.3months") ? t("dashboard.schedules.3months")
: t("dashboard.schedules.6months")} : t("dashboard.schedules.6months");
return (
<p className="shared-schedule-period">
{t("share.period")}: {periodLabel}
</p> </p>
);
})()}
</header> </header>
<div className="timeline"> <div className="timeline">
@@ -730,7 +741,8 @@ export function SharedSchedule() {
) : ( ) : (
<> <>
{/* Past days (when expanded) — rendered above toggle */} {/* Past days (when expanded) — rendered above toggle */}
{showPastDays && {!showOnlyToday &&
showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling) // Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
@@ -757,14 +769,18 @@ export function SharedSchedule() {
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : "";
return ( return (
<div <div
key={day.dateStr} key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`}
> >
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)} onClick={() => toggleDayCollapse(day.dateStr, true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -817,11 +833,18 @@ export function SharedSchedule() {
<div <div
className={med?.imageUrl ? "med-avatar clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
}
}}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div> </div>
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -837,9 +860,12 @@ export function SharedSchedule() {
<div key={dose.id} className="dose-item past"> <div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}> <div className={`dose-person ${isTaken ? "taken" : ""}`}>
@@ -859,7 +885,8 @@ export function SharedSchedule() {
disabled={isEmpty} disabled={isEmpty}
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -875,7 +902,8 @@ export function SharedSchedule() {
); );
})} })}
{/* Past days toggle */} {/* Past days toggle */}
{pastDays.length > 0 && {!showOnlyToday &&
pastDays.length > 0 &&
(() => { (() => {
const missedCount = missedPastDoseIds.length; const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id))); const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
@@ -894,6 +922,9 @@ export function SharedSchedule() {
}, 50); }, 50);
} }
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
}}
> >
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label"> <span className="past-days-label">
@@ -941,6 +972,9 @@ export function SharedSchedule() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -982,11 +1016,18 @@ export function SharedSchedule() {
<div <div
className={med?.imageUrl ? "med-avatar clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
}
}}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div> </div>
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1006,9 +1047,12 @@ export function SharedSchedule() {
> >
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div <div
@@ -1030,7 +1074,8 @@ export function SharedSchedule() {
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={isEmpty} disabled={isEmpty}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -1047,7 +1092,8 @@ export function SharedSchedule() {
})()} })()}
{/* Future days toggle — identical to DashboardPage */} {/* Future days toggle — identical to DashboardPage */}
{futureDays.length > 0 && {!showOnlyToday &&
futureDays.length > 0 &&
(() => { (() => {
const totalFutureDoses = futureDays.flatMap((d) => const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => m.doses.map((dose) => dose.id)) d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
@@ -1058,6 +1104,9 @@ export function SharedSchedule() {
<div <div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`} className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)} onClick={() => setShowFutureDays(!showFutureDays)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
}}
> >
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span> <span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label"> <span className="future-days-label">
@@ -1079,7 +1128,8 @@ export function SharedSchedule() {
})()} })()}
{/* Future days (when expanded) — identical to DashboardPage */} {/* Future days (when expanded) — identical to DashboardPage */}
{showFutureDays && {!showOnlyToday &&
showFutureDays &&
futureDays.map((day) => { futureDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id)); const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
@@ -1099,6 +1149,9 @@ export function SharedSchedule() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -1139,11 +1192,18 @@ export function SharedSchedule() {
<div <div
className={med?.imageUrl ? "med-avatar clickable" : ""} className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)} onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
}
}}
> >
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /> <MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</div> </div>
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span> <span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>} {med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1159,9 +1219,12 @@ export function SharedSchedule() {
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}> <div className={`dose-person ${isTaken ? "taken" : ""}`}>
@@ -1181,7 +1244,8 @@ export function SharedSchedule() {
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={true} disabled={true}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -1215,7 +1279,13 @@ export function SharedSchedule() {
{/* Image Lightbox */} {/* Image Lightbox */}
{lightboxImage && ( {lightboxImage && (
<div className="lightbox-overlay" onClick={closeLightbox}> <div
className="lightbox-overlay"
onClick={closeLightbox}
onKeyDown={(e) => {
if (e.key === "Escape") closeLightbox();
}}
>
<button className="lightbox-close" onClick={closeLightbox}> <button className="lightbox-close" onClick={closeLightbox}>
× ×
</button> </button>
@@ -1224,6 +1294,7 @@ 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()}
/> />
</div> </div>
)} )}
+23 -6
View File
@@ -36,8 +36,18 @@ export function UserFilterModal({
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser)); const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
return ( return (
<div className="modal-overlay" onClick={onClose}> <div
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}> className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
className="modal-content user-meds-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<button className="modal-close" onClick={onClose}> <button className="modal-close" onClick={onClose}>
× ×
</button> </button>
@@ -75,6 +85,12 @@ export function UserFilterModal({
onClearUser(); onClearUser();
onOpenMedDetail(med); onOpenMedDetail(med);
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClearUser();
onOpenMedDetail(med);
}
}}
> >
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" /> <MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info"> <div className="user-med-info">
@@ -82,18 +98,19 @@ export function UserFilterModal({
{med.genericName && <span className="user-med-generic">{med.genericName}</span>} {med.genericName && <span className="user-med-generic">{med.genericName}</span>}
{personIntakes.length > 0 && ( {personIntakes.length > 0 && (
<div className="user-med-intakes"> <div className="user-med-intakes">
{personIntakes.map((intake, idx) => { {personIntakes.map((intake) => {
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), { const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
return ( return (
<span key={idx} className="user-med-intake-item"> <span key={intakeKey} className="user-med-intake-item">
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")} {intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
{med.pillWeightMg != null && {med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "} ` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{t("form.blisters.every")} {intake.every}{" "} {intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr} {t("modal.at")} {timeStr}
</span> </span>
); );
})} })}
+1
View File
@@ -17,6 +17,7 @@ export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal"; export { MobileEditModal } from "./MobileEditModal";
export { PasswordInput } from "./PasswordInput"; export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal"; export { default as ProfileModal } from "./ProfileModal";
export { default as ReportModal } from "./ReportModal";
export type { ShareDialogProps } from "./ShareDialog"; export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog"; export { ShareDialog } from "./ShareDialog";
export { SharedSchedule } from "./SharedSchedule"; export { SharedSchedule } from "./SharedSchedule";
+135 -26
View File
@@ -1,12 +1,12 @@
import type React from "react"; import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks"; import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types"; import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale } from "../utils/formatters";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule"; import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
// ============================================================================= // =============================================================================
// Types // Types
@@ -72,6 +72,7 @@ export interface AppContextValue {
showClearMissedConfirm: boolean; showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void; setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string; getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>; markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>; undoDoseTaken: (doseId: string) => Promise<void>;
@@ -119,12 +120,15 @@ export interface AppContextValue {
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>; setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number; editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>; setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockLoosePills: number;
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean; editStockSaving: boolean;
editStockMedication: Medication | null;
loadRefillHistory: (medId: number) => Promise<void>; loadRefillHistory: (medId: number) => Promise<void>;
submitRefill: ( submitRefill: (
medId: number, medId: number,
editingId: number | null, editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<any>>, setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void, loadMeds: () => void,
usePrescription?: boolean usePrescription?: boolean
) => Promise<void>; ) => Promise<void>;
@@ -175,8 +179,20 @@ export interface AppContextValue {
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>; setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown; pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>; setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importResult: { medications: number; doses: number; shares: number } | null; importResult: {
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>; medications: number;
doses: number;
refills: number;
shares: number;
} | null;
setImportResult: React.Dispatch<
React.SetStateAction<{
medications: number;
doses: number;
refills: number;
shares: number;
} | null>
>;
handleExport: (includeImages?: boolean) => Promise<void>; handleExport: (includeImages?: boolean) => Promise<void>;
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void; handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleImportConfirm: () => Promise<void>; handleImportConfirm: () => Promise<void>;
@@ -197,7 +213,17 @@ export interface AppContextValue {
// Context // Context
// ============================================================================= // =============================================================================
const AppContext = createContext<AppContextValue | null>(null); const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
const AppContext = (() => {
const globalRef = globalThis as typeof globalThis & {
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
};
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
}
return globalRef[APP_CONTEXT_SINGLETON_KEY];
})();
// Helper for user-specific localStorage keys // Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
@@ -227,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Modal state // Modal state
const [selectedMed, setSelectedMed] = useState<Medication | null>(null); const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
const selectedMedIdRef = useRef<number | null>(null);
const medDetailOpenedAtRef = useRef(0);
const medDetailCloseInFlightRef = useRef(false);
useEffect(() => {
selectedMedIdRef.current = selectedMed?.id ?? null;
if (!selectedMed) {
medDetailCloseInFlightRef.current = false;
}
}, [selectedMed]);
const [showImageLightbox, setShowImageLightbox] = useState(false); const [showImageLightbox, setShowImageLightbox] = useState(false);
const imageLightboxOpenedAtRef = useRef(0);
const imageLightboxCloseInFlightRef = useRef(false);
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null); const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
const scheduleLightboxOpenedAtRef = useRef(0);
const scheduleLightboxCloseInFlightRef = useRef(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null); const [selectedUser, setSelectedUser] = useState<string | null>(null);
useEffect(() => {
if (!showImageLightbox) {
imageLightboxCloseInFlightRef.current = false;
}
}, [showImageLightbox]);
useEffect(() => {
if (!scheduleLightboxImage) {
scheduleLightboxCloseInFlightRef.current = false;
}
}, [scheduleLightboxImage]);
// Export/Import state // Export/Import state
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
@@ -237,7 +286,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false); const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null); const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null); const [importResult, setImportResult] = useState<{
medications: number;
doses: number;
refills: number;
shares: number;
} | null>(null);
// Load user-specific scheduleDays when user changes // Load user-specific scheduleDays when user changes
useEffect(() => { useEffect(() => {
@@ -349,7 +403,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Normal/High stock // Normal/High stock
return "success"; return "success";
}); });
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
return statuses.includes("danger") ? "danger" : fallbackStatus;
}, },
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays] [coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
); );
@@ -435,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Modal helpers with browser history support // Modal helpers with browser history support
const openMedDetail = useCallback( const openMedDetail = useCallback(
(med: Medication) => { (med: Medication) => {
if (selectedMedIdRef.current === med.id) return;
selectedMedIdRef.current = med.id;
medDetailOpenedAtRef.current = Date.now();
medDetailCloseInFlightRef.current = false;
setSelectedMed(med); setSelectedMed(med);
refill.setRefillHistoryExpanded(false); refill.setRefillHistoryExpanded(false);
refill.loadRefillHistory(med.id); refill.loadRefillHistory(med.id);
@@ -444,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
); );
const closeMedDetail = useCallback(() => { const closeMedDetail = useCallback(() => {
if (selectedMed) { if (!selectedMed || medDetailCloseInFlightRef.current) return;
window.history.back();
// Ignore ultra-fast close requests caused by rapid double-click races
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "medDetail") {
// State already popped by another event: close locally without another back step.
selectedMedIdRef.current = null;
setSelectedMed(null);
return;
} }
medDetailCloseInFlightRef.current = true;
window.history.back();
}, [selectedMed]); }, [selectedMed]);
const openImageLightbox = useCallback(() => { const openImageLightbox = useCallback(() => {
if (showImageLightbox) return;
imageLightboxOpenedAtRef.current = Date.now();
imageLightboxCloseInFlightRef.current = false;
setShowImageLightbox(true); setShowImageLightbox(true);
window.history.pushState({ modal: "imageLightbox" }, ""); window.history.pushState({ modal: "imageLightbox" }, "");
}, []);
const closeImageLightbox = useCallback(() => {
if (showImageLightbox) {
window.history.back();
}
}, [showImageLightbox]); }, [showImageLightbox]);
const openScheduleLightbox = useCallback((imageUrl: string) => { const closeImageLightbox = useCallback(() => {
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "imageLightbox") {
setShowImageLightbox(false);
return;
}
imageLightboxCloseInFlightRef.current = true;
window.history.back();
}, [showImageLightbox]);
const openScheduleLightbox = useCallback(
(imageUrl: string) => {
if (scheduleLightboxImage) return;
scheduleLightboxOpenedAtRef.current = Date.now();
scheduleLightboxCloseInFlightRef.current = false;
setScheduleLightboxImage(imageUrl); setScheduleLightboxImage(imageUrl);
window.history.pushState({ modal: "scheduleLightbox" }, ""); window.history.pushState({ modal: "scheduleLightbox" }, "");
}, []); },
[scheduleLightboxImage]
);
const closeScheduleLightbox = useCallback(() => { const closeScheduleLightbox = useCallback(() => {
if (scheduleLightboxImage) { if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
window.history.back(); if (Date.now() - scheduleLightboxOpenedAtRef.current < 320) return;
const currentState = window.history.state as { modal?: string } | null;
if (currentState?.modal !== "scheduleLightbox") {
setScheduleLightboxImage(null);
return;
} }
scheduleLightboxCloseInFlightRef.current = true;
window.history.back();
}, [scheduleLightboxImage]); }, [scheduleLightboxImage]);
const openUserFilter = useCallback((person: string) => { const openUserFilter = useCallback(
(person: string) => {
if (selectedUser === person) return;
setSelectedUser(person); setSelectedUser(person);
window.history.pushState({ modal: "userFilter", person }, ""); window.history.pushState({ modal: "userFilter", person }, "");
}, []); },
[selectedUser]
);
const closeUserFilter = useCallback(() => { const closeUserFilter = useCallback(() => {
if (selectedUser) { if (selectedUser) {
@@ -505,9 +605,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
const dateStr = new Date().toISOString().split("T")[0]; const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const userPart = user?.username ? `-${user.username}` : "";
a.href = url; a.href = url;
a.download = `${t("exportImport.downloadFilename")}-${dateStr}.json`; a.download = `${t("exportImport.downloadFilename")}${userPart}-${dateStr}.json`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@@ -517,7 +619,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
} }
setExporting(false); setExporting(false);
}, },
[t] [t, user?.username]
); );
// Handle file selection for import // Handle file selection for import
@@ -581,6 +683,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setImportResult({ setImportResult({
medications: data.imported?.medications || 0, medications: data.imported?.medications || 0,
doses: data.imported?.doseHistory || 0, doses: data.imported?.doseHistory || 0,
refills: data.imported?.refillHistory || 0,
shares: data.imported?.shareLinks || 0, shares: data.imported?.shareLinks || 0,
}); });
@@ -623,6 +726,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders || settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode || settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus || settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
settings.expiryWarningDays !== savedSettings.expiryWarningDays settings.expiryWarningDays !== savedSettings.expiryWarningDays
); );
}, [settingsHook.settings, settingsHook.savedSettings]); }, [settingsHook.settings, settingsHook.savedSettings]);
@@ -706,6 +811,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showClearMissedConfirm: doses.showClearMissedConfirm, showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm, setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId, getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses, countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken, markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken, undoDoseTaken: doses.undoDoseTaken,
@@ -753,7 +859,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setEditStockFullBlisters: refill.setEditStockFullBlisters, setEditStockFullBlisters: refill.setEditStockFullBlisters,
editStockPartialBlisterPills: refill.editStockPartialBlisterPills, editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills, setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
editStockLoosePills: refill.editStockLoosePills,
setEditStockLoosePills: refill.setEditStockLoosePills,
editStockSaving: refill.editStockSaving, editStockSaving: refill.editStockSaving,
editStockMedication: refill.editStockMedication,
loadRefillHistory: refill.loadRefillHistory, loadRefillHistory: refill.loadRefillHistory,
submitRefill: refill.submitRefill, submitRefill: refill.submitRefill,
submitStockCorrection: refill.submitStockCorrection, submitStockCorrection: refill.submitStockCorrection,
+1
View File
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm"; export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
export type { UseMedicationsReturn } from "./useMedications"; export type { UseMedicationsReturn } from "./useMedications";
export { useMedications } from "./useMedications"; export { useMedications } from "./useMedications";
export { useModalHistory } from "./useModalHistory";
export type { UseRefillReturn } from "./useRefill"; export type { UseRefillReturn } from "./useRefill";
export { useRefill } from "./useRefill"; export { useRefill } from "./useRefill";
export type { Settings, UseSettingsReturn } from "./useSettings"; export type { Settings, UseSettingsReturn } from "./useSettings";
+30
View File
@@ -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,
+5 -6
View File
@@ -151,7 +151,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
if (error) errors[f] = error; if (error) errors[f] = error;
}); });
setFieldErrors(errors); setFieldErrors(errors);
}, [form.name, form.genericName, form.notes, validateField]); }, [form.name, form.genericName, form.notes, validateField, form]);
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => { const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
setForm((prev) => { setForm((prev) => {
@@ -190,6 +190,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setEditingId(med.id); setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved setFormSaved(true); // Existing medication is already saved
setFieldErrors({}); // Prevent one-frame stale error highlight from previous/default form state
// Parse intakes - prefer new format, fallback to legacy blisters // Parse intakes - prefer new format, fallback to legacy blisters
const intakesFromApi = const intakesFromApi =
@@ -215,6 +216,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills); const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills); const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : "";
const editForm: FormState = { const editForm: FormState = {
name: med.name, name: med.name,
genericName: med.genericName ?? "", genericName: med.genericName ?? "",
@@ -223,11 +225,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
packCount: String(med.packCount), packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack), blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister), pillsPerBlister: String(med.pillsPerBlister),
totalPills: med.totalPills totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
? String(med.totalPills)
: med.packageType === "bottle" && med.looseTablets
? String(med.looseTablets)
: "",
looseTablets: String(med.looseTablets), looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg", doseUnit: med.doseUnit ?? "mg",
@@ -262,6 +260,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setPendingImage(null); setPendingImage(null);
setPendingImagePreview(null); setPendingImagePreview(null);
setTakenByInput(""); setTakenByInput("");
setFieldErrors({});
setFormSaved(false); setFormSaved(false);
const newForm = defaultForm(); const newForm = defaultForm();
setForm(newForm); setForm(newForm);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
/**
* Push a history entry when a modal opens so the browser back button closes it.
* On popstate (back), calls `onClose` to dismiss the modal.
*/
export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () => void) {
const pushedRef = useRef(false);
useEffect(() => {
if (isOpen) {
window.history.pushState({ modal: modalKey }, "");
pushedRef.current = true;
} else if (pushedRef.current) {
pushedRef.current = false;
}
}, [isOpen, modalKey]);
useEffect(() => {
if (!isOpen) return;
const handlePopState = () => {
if (pushedRef.current) {
pushedRef.current = false;
onClose();
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [isOpen, onClose]);
}
+82 -30
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { Coverage, FormState, Medication, RefillEntry } from "../types"; import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import { getMedTotal, getPackageSize } from "../types"; import { getMedTotal, getPackageSize } from "../types";
@@ -24,7 +24,10 @@ export interface UseRefillReturn {
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>; setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number; editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>; setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockLoosePills: number;
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean; editStockSaving: boolean;
editStockMedication: Medication | null;
// Actions // Actions
loadRefillHistory: (medId: number) => Promise<void>; loadRefillHistory: (medId: number) => Promise<void>;
@@ -56,7 +59,9 @@ export function useRefill(): UseRefillReturn {
const [showEditStockModal, setShowEditStockModal] = useState(false); const [showEditStockModal, setShowEditStockModal] = useState(false);
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0); const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0); const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
const [editStockLoosePills, setEditStockLoosePills] = useState(0);
const [editStockSaving, setEditStockSaving] = useState(false); const [editStockSaving, setEditStockSaving] = useState(false);
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
// Load refill history for a medication // Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => { const loadRefillHistory = useCallback(async (medId: number) => {
@@ -132,42 +137,60 @@ export function useRefill(): UseRefillReturn {
if (!selectedMed) return; if (!selectedMed) return;
setEditStockSaving(true); setEditStockSaving(true);
try { try {
// Auto-convert: handle full blister and negative partial blister // Clamp all fields to non-negative values.
let finalFullBlisters = editStockFullBlisters; let finalFullBlisters = Math.max(0, editStockFullBlisters);
let finalPartialPills = editStockPartialBlisterPills; let finalPartialPills =
selectedMed.packageType === "bottle"
? Math.max(0, editStockPartialBlisterPills)
: Math.max(0, editStockPartialBlisterPills);
const finalLoosePills = Math.max(0, editStockLoosePills);
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial // Canonicalize blister values: partial overflow becomes additional full blisters.
if (finalPartialPills >= selectedMed.pillsPerBlister) { if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
finalFullBlisters += 1; finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
finalPartialPills = 0; finalPartialPills %= selectedMed.pillsPerBlister;
} }
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister) // Structural max = sealed package capacity only (no looseTablets offset).
if (finalPartialPills < 0 && finalFullBlisters > 0) { const structuralMax =
finalFullBlisters -= 1; selectedMed.packageType === "bottle"
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills; ? (selectedMed.totalPills ?? getPackageSize(selectedMed))
} : selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
// Ensure we don't go negative // For blister meds, only sealed pills are capped to package size.
if (finalPartialPills < 0) finalPartialPills = 0; // Loose pills are extra and can be above package size.
if (finalFullBlisters < 0) finalFullBlisters = 0; const desiredTotal =
selectedMed.packageType === "bottle"
// What the user says they have RIGHT NOW = the new DB total ? Math.min(structuralMax, Math.max(0, finalPartialPills))
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills; : Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
finalLoosePills;
// The "base" from DB structure (without any stockAdjustment)
// Use getPackageSize() which handles both blister and bottle types correctly
const baseTotal = getPackageSize(selectedMed);
// The "base" from DB structure used to compute stockAdjustment differs by type:
// - Bottle: looseTablets is the base (not changed during correction)
// - Blister: use structuralMax + finalLoosePills as the new base so that
// updating looseTablets in the DB doesn't cause a stale-split display bug.
const baseTotal =
selectedMed.packageType === "bottle"
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
// stockAdjustment = what we need to make getMedTotal() return desiredTotal // stockAdjustment = what we need to make getMedTotal() return desiredTotal
const newStockAdjustment = desiredTotal - baseTotal; const newStockAdjustment = desiredTotal - baseTotal;
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt // For blister corrections also send the new looseTablets value so the DB
// reflects the actual loose count (avoids stale-split display on reload).
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
stockAdjustment: newStockAdjustment,
};
if (selectedMed.packageType !== "bottle") {
patchBody.looseTablets = finalLoosePills;
}
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, { const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ stockAdjustment: newStockAdjustment }), body: JSON.stringify(patchBody),
}); });
if (res.ok) { if (res.ok) {
// Close edit stock modal via history back // Close edit stock modal via history back
@@ -182,7 +205,7 @@ export function useRefill(): UseRefillReturn {
} }
setEditStockSaving(false); setEditStockSaving(false);
}, },
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal] [editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
); );
const openRefillModal = useCallback(() => { const openRefillModal = useCallback(() => {
@@ -198,25 +221,51 @@ export function useRefill(): UseRefillReturn {
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => { const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
if (!selectedMed) return; if (!selectedMed) return;
setEditStockMedication(selectedMed);
// Get current stock from coverage (after consumption) // Get current stock from coverage (after consumption)
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name); const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const dbTotal = getMedTotal(selectedMed); const dbTotal = getMedTotal(selectedMed);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal; const currentStock = Math.max(0, medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal);
// Simply divide into full blisters and partial // Bottle correction uses only total pills input.
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister); // For blister, keep loose pills separated from sealed blister/partial counts.
const partialPills = currentStock % selectedMed.pillsPerBlister; const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
const sealedPills = Math.max(0, currentStock - knownLoose);
const fullBlisters =
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
const partialPills =
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
// Pre-fill with current values // Pre-fill with current values
setEditStockFullBlisters(fullBlisters); setEditStockFullBlisters(fullBlisters);
setEditStockPartialBlisterPills(partialPills); setEditStockPartialBlisterPills(partialPills);
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
setShowEditStockModal(true); setShowEditStockModal(true);
window.history.pushState({ modal: "editStock" }, ""); window.history.pushState({ modal: "editStock" }, "");
}, []); }, []);
const closeEditStockModal = useCallback(() => { const closeEditStockModal = useCallback(() => {
if (showEditStockModal) { if (showEditStockModal) {
let popstateHandled = false;
const handlePopstate = () => {
popstateHandled = true;
};
window.addEventListener("popstate", handlePopstate, { once: true });
window.history.back(); window.history.back();
// Fallback for cases where no history entry exists for edit stock.
window.setTimeout(() => {
if (!popstateHandled) {
window.removeEventListener("popstate", handlePopstate);
setShowEditStockModal(false);
}
}, 150);
}
}, [showEditStockModal]);
useEffect(() => {
if (!showEditStockModal) {
setEditStockMedication(null);
} }
}, [showEditStockModal]); }, [showEditStockModal]);
@@ -239,7 +288,10 @@ export function useRefill(): UseRefillReturn {
setEditStockFullBlisters, setEditStockFullBlisters,
editStockPartialBlisterPills, editStockPartialBlisterPills,
setEditStockPartialBlisterPills, setEditStockPartialBlisterPills,
editStockLoosePills,
setEditStockLoosePills,
editStockSaving, editStockSaving,
editStockMedication,
loadRefillHistory, loadRefillHistory,
submitRefill, submitRefill,
submitStockCorrection, submitStockCorrection,
+10 -5
View File
@@ -46,6 +46,9 @@ export interface Settings {
shoutrrrPrescriptionReminders: boolean; shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual"; stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean; shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
expiryWarningDays: number; expiryWarningDays: number;
} }
@@ -90,6 +93,9 @@ const defaultSettings: Settings = {
shoutrrrPrescriptionReminders: true, shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic", stockCalculationMode: "automatic",
shareStockStatus: true, shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
expiryWarningDays: 30, expiryWarningDays: 30,
}; };
@@ -224,6 +230,9 @@ export function useSettings(): UseSettingsReturn {
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders, shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode, stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus, shareStockStatus: settingsToSave.shareStockStatus,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language, language: i18n.language,
smtpHost: settingsToSave.smtpHost, smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort, smtpPort: settingsToSave.smtpPort,
@@ -240,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
const updatedSettings = { const updatedSettings = { ...settingsToSave };
...settingsToSave,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
setSettings(updatedSettings); setSettings(updatedSettings);
setSettingsSaving(false); setSettingsSaving(false);
setSavedSettings(updatedSettings); setSavedSettings(updatedSettings);
+110 -15
View File
@@ -76,7 +76,7 @@
"emptyStock_other": "{{count}} Medikamente leer", "emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament kritisch niedrig", "lowWarning": "{{count}} Medikament kritisch niedrig",
"lowWarning_other": "{{count}} Medikamente kritisch niedrig", "lowWarning_other": "{{count}} Medikamente kritisch niedrig",
"waitingFirstCheck": "Warte auf erste Prüfung", "waitingFirstCheck": "Warte auf die erste Prüfung",
"type": "Typ", "type": "Typ",
"typeStock": "Bestand", "typeStock": "Bestand",
"typeIntake": "Einnahme", "typeIntake": "Einnahme",
@@ -152,14 +152,17 @@
} }
}, },
"form": { "form": {
"editEntry": "Medikament bearbeiten", "editEntry": "Bearbeiten",
"viewEntry": "Medikament ansehen", "editEntryWithName": "Bearbeiten: {{name}}",
"viewEntry": "Ansehen",
"newEntry": "Neues Medikament", "newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten", "badge": "Packungen + lose Tabletten",
"sections": { "sections": {
"general": "Allgemein", "general": "Allgemein",
"stock": "Bestand & Dosis", "stock": "Package",
"prescription": "Rezept" "prescription": "Rezept",
"prescriptionAndRefill": "Rezept & Nachfüllen",
"schedule": "Einnahme"
}, },
"commercialName": "Handelsname", "commercialName": "Handelsname",
"genericName": "Wirkstoff", "genericName": "Wirkstoff",
@@ -175,7 +178,7 @@
"loosePills": "Lose Tabletten", "loosePills": "Lose Tabletten",
"pillWeight": "Dosis pro Tablette", "pillWeight": "Dosis pro Tablette",
"total": "Gesamt (Tabletten)", "total": "Gesamt (Tabletten)",
"medicationStartDate": "Medikations-Startdatum", "medicationStartDate": "Startdatum der Medikation",
"expiryDate": "Ablaufdatum", "expiryDate": "Ablaufdatum",
"notes": "Notizen", "notes": "Notizen",
"medicationImage": "Medikamentenbild", "medicationImage": "Medikamentenbild",
@@ -240,7 +243,7 @@
"stockReminders": "Bestands-Erinnerungen", "stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-Erinnerungen", "intakeReminders": "Einnahme-Erinnerungen",
"prescriptionReminders": "Rezept-Erinnerungen", "prescriptionReminders": "Rezept-Erinnerungen",
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.", "enableHint": "Aktiviere mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen", "skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden", "skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen", "repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
@@ -279,7 +282,7 @@
"automatic": "Automatisch", "automatic": "Automatisch",
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert", "automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
"manual": "Manuell", "manual": "Manuell",
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden", "manualDesc": "Bestand wird nur reduziert, wenn Dosen als genommen markiert werden",
"thresholds": "Schwellenwerte", "thresholds": "Schwellenwerte",
"criticalStockDays": "Kritisch (Tage)", "criticalStockDays": "Kritisch (Tage)",
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit", "criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
@@ -287,10 +290,22 @@
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte", "lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
"highStockDays": "Hoch (Tage)", "highStockDays": "Hoch (Tage)",
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist", "highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
"thresholdValidation": "Werte müssen sein: Kritisch < Niedrig < Hoch", "thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
"shareStockStatus": "Bestand auf geteilten Links anzeigen", "shareStockStatus": "Bestand auf geteilten Links anzeigen",
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen" "shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
}, },
"timeline": {
"title": "Allgemeine UI",
"upcomingSection": "Bevorstehender Zeitplan",
"upcomingTodayOnly": "Nur heute anzeigen",
"upcomingTodayOnlyDesc": "Vergangene und zukünftige Tage ausblenden und im Dashboard nur den heutigen Zeitplan anzeigen.",
"dashboardSectionOrder": "Dashboard-Layout",
"swapDashboardSections": "Bevorstehenden Zeitplan vor Medikamentenübersicht anzeigen",
"swapDashboardSectionsDesc": "Wenn aktiviert, wird der Bereich mit bevorstehenden Einnahmen über der Medikamentenübersicht angezeigt.",
"sharedSection": "Geteilter Zeitplan",
"shareScheduleTodayOnly": "Geteilte Links zeigen nur heute",
"shareScheduleTodayOnlyDesc": "Vergangene und zukünftige Tage in geteilten Zeitplänen ausblenden und nur heutige Einträge zeigen."
},
"stockReminder": { "stockReminder": {
"title": "Bestands-Erinnerung", "title": "Bestands-Erinnerung",
"description": "Bestands-Erinnerungen aktivieren", "description": "Bestands-Erinnerungen aktivieren",
@@ -336,6 +351,7 @@
}, },
"tooltips": { "tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert", "intakeReminders": "Einnahme-Erinnerungen aktiviert",
"automaticTaken": "Automatisch eingenommen",
"hasNotes": "Hat Notizen", "hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen", "stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln", "lightMode": "Zum hellen Modus wechseln",
@@ -349,7 +365,8 @@
}, },
"dose": { "dose": {
"takenBy": "eingenommen von", "takenBy": "eingenommen von",
"markAsTaken": "Als eingenommen markieren" "markAsTaken": "Als eingenommen markieren",
"take": "Nehmen"
}, },
"auth": { "auth": {
"login": "Anmelden", "login": "Anmelden",
@@ -377,7 +394,7 @@
"checkEmail": "E-Mail überprüfen", "checkEmail": "E-Mail überprüfen",
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.", "resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
"passwordReset": "Passwort zurückgesetzt", "passwordReset": "Passwort zurückgesetzt",
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...", "passwordResetSuccess": "Dein Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
"profileUpdated": "Profil erfolgreich aktualisiert", "profileUpdated": "Profil erfolgreich aktualisiert",
"rememberMe": "Angemeldet bleiben", "rememberMe": "Angemeldet bleiben",
"localAccount": "Lokales Konto", "localAccount": "Lokales Konto",
@@ -414,7 +431,7 @@
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})", "maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} Zeichen" "tooLong": "{{current}}/{{max}} Zeichen"
}, },
"saved": "Gespeichert", "saved": "Gespeichert",
"save": "Speichern", "save": "Speichern",
"back": "Zurück", "back": "Zurück",
"cancel": "Abbrechen", "cancel": "Abbrechen",
@@ -434,6 +451,8 @@
"of": "von", "of": "von",
"loose": "lose", "loose": "lose",
"none": "Kein", "none": "Kein",
"daily": "täglich",
"everyNDays": "alle {{count}} Tage",
"day": "Tag", "day": "Tag",
"days": "Tage", "days": "Tage",
"blister": "Blister", "blister": "Blister",
@@ -445,7 +464,9 @@
"pillsTotal": "{{count}} Tabletten gesamt", "pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt", "pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt", "pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max" "max": "max",
"on": "An",
"off": "Aus"
}, },
"share": { "share": {
"button": "Teilen", "button": "Teilen",
@@ -474,7 +495,7 @@
} }
}, },
"exportImport": { "exportImport": {
"title": "Daten Export / Import", "title": "Datenexport / -import",
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.", "description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
"exportTitle": "Export", "exportTitle": "Export",
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.", "exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
@@ -498,10 +519,13 @@
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.", "confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!", "confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
"confirmButton": "Ja, alles ersetzen", "confirmButton": "Ja, alles ersetzen",
"confirmImportEmpty": "Daten importieren?",
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
"confirmButtonEmpty": "Importieren",
"cancelButton": "Abbrechen", "cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert", "exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert", "importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links", "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
"importError": "Daten konnten nicht importiert werden", "importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.", "invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
@@ -535,11 +559,19 @@
}, },
"editStock": { "editStock": {
"title": "Bestand korrigieren", "title": "Bestand korrigieren",
"buttonLabel": "Bestand/Angebrochene Blister korrigieren",
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.", "hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
"totalPills": "Gesamte Tabletten", "totalPills": "Gesamte Tabletten",
"fullBlisters": "Volle Blister", "fullBlisters": "Volle Blister",
"partialBlisterPills": "Angebrochener Blister", "partialBlisterPills": "Angebrochener Blister",
"loosePills": "Lose Tabletten",
"pillsPerBlister": "(je {{count}} Tabletten)", "pillsPerBlister": "(je {{count}} Tabletten)",
"packageSize": "Packungsgröße: {{count}} Tabletten",
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
"decreaseValue": "Wert verringern",
"increaseValue": "Wert erhöhen",
"currentTotal": "Aktueller Bestand", "currentTotal": "Aktueller Bestand",
"newTotal": "Neuer Bestand", "newTotal": "Neuer Bestand",
"difference": "Differenz", "difference": "Differenz",
@@ -564,5 +596,68 @@
"copyright": "© {{year}} Daniel Volz", "copyright": "© {{year}} Daniel Volz",
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement", "madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
"techStack": "Entwickelt mit React, Fastify & SQLite" "techStack": "Entwickelt mit React, Fastify & SQLite"
},
"report": {
"button": "Bericht",
"title": "Medikamentenbericht",
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
"selectAll": "Alle auswählen",
"deselectAll": "Alle abwählen",
"activeMeds": "Aktive Medikamente",
"obsoleteMeds": "Obsolete Medikamente",
"format": "Format",
"formatTxt": "Klartext (.txt)",
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Drucken)",
"generate": "Erstellen",
"generating": "Wird erstellt...",
"noSelection": "Wähle mindestens ein Medikament aus",
"filterByPerson": "Bericht für",
"allPeople": "Alle Personen",
"docTitle": "Medikamentenbericht",
"docGenerated": "Erstellt am",
"docGeneral": "Allgemein",
"docCommercialName": "Handelsname",
"docGenericName": "Wirkstoff",
"docTakenBy": "Eingenommen von",
"docStartDate": "Startdatum",
"docObsoleteSince": "Obsolet seit",
"docStatus": "Status",
"docStatusActive": "Aktiv",
"docStatusObsolete": "Obsolet",
"docPackage": "Verpackung",
"docPackageType": "Verpackungsart",
"docBlister": "Blisterpackung",
"docBottle": "Pillendose",
"docPacks": "Packungen",
"docBlistersPerPack": "Blister pro Packung",
"docPillsPerBlister": "Tabletten pro Blister",
"docTotalCapacity": "Gesamtkapazität",
"docCurrentStock": "Aktueller Bestand",
"docLoosePills": "Lose Tabletten",
"docDose": "Dosis",
"docDosePerPill": "Dosis pro Tablette",
"docExpiryDate": "Ablaufdatum",
"docNotes": "Notizen",
"docIntakeSchedule": "Einnahmeplan",
"docIntakeEntry": "{{usage}} Tablette(n) alle {{every}} Tag(e) ab {{start}}",
"docIntakeTakenBy": "eingenommen von {{person}}",
"docIntakeReminder": "Erinnerung aktiv",
"docPrescription": "Rezept",
"docAuthorizedRefills": "Genehmigte Nachfüllungen",
"docRemainingRefills": "Verbleibende Nachfüllungen",
"docPrescriptionExpiry": "Rezeptablauf",
"docIntakeHistory": "Einnahme-Verlauf",
"docDosesTaken": "Eingenommene Dosen",
"docDosesTakenAutomatic": "Automatisch eingenommen",
"docDosesDismissed": "Verworfene Dosen",
"docFirstDose": "Erste Dosis",
"docLastDose": "Letzte Dosis",
"docRefillHistory": "Nachfüll-Verlauf",
"docRefillEntry": "{{date}}: +{{packs}} Packungen, +{{loose}} Tabletten",
"docRefillPrescription": "(Rezept-Nachfüllung)",
"docNoRefills": "Keine Nachfüllungen erfasst",
"docNoDoses": "Keine Dosen erfasst",
"docPrintInstruction": "Nutze die Druckfunktion deines Browsers (Strg+P / ⌘P) um als PDF zu speichern."
} }
} }
+111 -16
View File
@@ -70,10 +70,10 @@
"inDays_one": "in {{days}} day", "inDays_one": "in {{days}} day",
"inDays_other": "in {{days}} days", "inDays_other": "in {{days}} days",
"noRemindersNeeded": "No reminders needed", "noRemindersNeeded": "No reminders needed",
"needRefill": "{{count}} med needs refill", "needRefill": "{{count}} medication needs refill",
"needRefill_other": "{{count}} meds need refill", "needRefill_other": "{{count}} medications need refill",
"emptyStock": "{{count}} med is empty", "emptyStock": "{{count}} medication is empty",
"emptyStock_other": "{{count}} meds are empty", "emptyStock_other": "{{count}} medications are empty",
"lowWarning": "{{count}} medication running critically low", "lowWarning": "{{count}} medication running critically low",
"lowWarning_other": "{{count}} medications running critically low", "lowWarning_other": "{{count}} medications running critically low",
"waitingFirstCheck": "Waiting for first check", "waitingFirstCheck": "Waiting for first check",
@@ -84,10 +84,10 @@
"channelEmail": "Email", "channelEmail": "Email",
"channelPush": "Push", "channelPush": "Push",
"channelBoth": "Email + Push", "channelBoth": "Email + Push",
"criticalMeds": "{{count}} medication critical", "criticalMeds": "{{count}} medication is critical",
"criticalMeds_other": "{{count}} medications critical", "criticalMeds_other": "{{count}} medications are critical",
"lowMeds": "{{count}} medication low", "lowMeds": "{{count}} medication is low",
"lowMeds_other": "{{count}} medications low", "lowMeds_other": "{{count}} medications are low",
"prescriptionNeeds": "Prescription low", "prescriptionNeeds": "Prescription low",
"prescriptionLowMeds": "{{count}} prescription low", "prescriptionLowMeds": "{{count}} prescription low",
"prescriptionLowMeds_other": "{{count}} prescriptions low", "prescriptionLowMeds_other": "{{count}} prescriptions low",
@@ -152,14 +152,17 @@
} }
}, },
"form": { "form": {
"editEntry": "Edit medication", "editEntry": "Edit",
"viewEntry": "View medication", "editEntryWithName": "Edit: {{name}}",
"viewEntry": "View",
"newEntry": "New medication", "newEntry": "New medication",
"badge": "Packs + loose pills", "badge": "Packs + loose pills",
"sections": { "sections": {
"general": "General", "general": "General",
"stock": "Stock & Dose", "stock": "Package",
"prescription": "Prescription" "prescription": "Prescription",
"prescriptionAndRefill": "Rx & Refill",
"schedule": "Schedule"
}, },
"commercialName": "Commercial Name", "commercialName": "Commercial Name",
"genericName": "Generic Name", "genericName": "Generic Name",
@@ -291,6 +294,18 @@
"shareStockStatus": "Show Stock on Shared Links", "shareStockStatus": "Show Stock on Shared Links",
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users" "shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
}, },
"timeline": {
"title": "General UI",
"upcomingSection": "Upcoming Schedule",
"upcomingTodayOnly": "Show only today",
"upcomingTodayOnlyDesc": "Hide past and future days and show only today's schedule on the dashboard.",
"dashboardSectionOrder": "Dashboard Layout",
"swapDashboardSections": "Show Upcoming Schedules before Medication Overview",
"swapDashboardSectionsDesc": "When enabled, the dashboard prioritizes the upcoming schedule section above the medication overview section.",
"sharedSection": "Shared Schedule",
"shareScheduleTodayOnly": "Shared links show only today",
"shareScheduleTodayOnlyDesc": "Hide past and future days on shared schedule links and show only today's entries."
},
"stockReminder": { "stockReminder": {
"title": "Stock Reminder", "title": "Stock Reminder",
"description": "Enable stock reminders", "description": "Enable stock reminders",
@@ -336,6 +351,7 @@
}, },
"tooltips": { "tooltips": {
"intakeReminders": "Intake reminders enabled", "intakeReminders": "Intake reminders enabled",
"automaticTaken": "Automatically taken",
"hasNotes": "Has notes", "hasNotes": "Has notes",
"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",
@@ -349,7 +365,8 @@
}, },
"dose": { "dose": {
"takenBy": "taken by", "takenBy": "taken by",
"markAsTaken": "Mark as taken" "markAsTaken": "Mark as taken",
"take": "Take"
}, },
"auth": { "auth": {
"login": "Login", "login": "Login",
@@ -414,7 +431,7 @@
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})", "maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} characters" "tooLong": "{{current}}/{{max}} characters"
}, },
"saved": "Saved", "saved": "Saved",
"save": "Save", "save": "Save",
"back": "Back", "back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
@@ -434,6 +451,8 @@
"of": "of", "of": "of",
"loose": "loose", "loose": "loose",
"none": "None", "none": "None",
"daily": "daily",
"everyNDays": "every {{count}} days",
"day": "day", "day": "day",
"days": "days", "days": "days",
"blister": "blister", "blister": "blister",
@@ -445,7 +464,9 @@
"pillsTotal": "{{count}} pills total", "pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total", "pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total", "pillsTotal_other": "{{count}} pills total",
"max": "max" "max": "max",
"on": "On",
"off": "Off"
}, },
"share": { "share": {
"button": "Share", "button": "Share",
@@ -498,10 +519,13 @@
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.", "confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
"confirmImportWarning": "This action cannot be undone!", "confirmImportWarning": "This action cannot be undone!",
"confirmButton": "Yes, Replace All", "confirmButton": "Yes, Replace All",
"confirmImportEmpty": "Import Data?",
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
"confirmButtonEmpty": "Import",
"cancelButton": "Cancel", "cancelButton": "Cancel",
"exportSuccess": "Data exported successfully", "exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully", "importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links", "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
"importError": "Failed to import data", "importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.", "invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
@@ -535,11 +559,19 @@
}, },
"editStock": { "editStock": {
"title": "Correct Stock", "title": "Correct Stock",
"buttonLabel": "Correct Stock/Partial Blister",
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.", "hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
"totalPills": "Total pills", "totalPills": "Total pills",
"fullBlisters": "Full blisters", "fullBlisters": "Full blisters",
"partialBlisterPills": "Partial blister", "partialBlisterPills": "Partial blister",
"loosePills": "Loose pills",
"pillsPerBlister": "({{count}} pills each)", "pillsPerBlister": "({{count}} pills each)",
"packageSize": "Package size: {{count}} pills",
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
"decreaseValue": "Decrease value",
"increaseValue": "Increase value",
"currentTotal": "Current total", "currentTotal": "Current total",
"newTotal": "New total", "newTotal": "New total",
"difference": "Difference", "difference": "Difference",
@@ -564,5 +596,68 @@
"copyright": "© {{year}} Daniel Volz", "copyright": "© {{year}} Daniel Volz",
"madeWith": "Made with ❤️ for better health management", "madeWith": "Made with ❤️ for better health management",
"techStack": "Built with React, Fastify & SQLite" "techStack": "Built with React, Fastify & SQLite"
},
"report": {
"button": "Report",
"title": "Medication Report",
"description": "Generate a document with detailed medication information for your doctor or personal records.",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"activeMeds": "Active Medications",
"obsoleteMeds": "Obsolete Medications",
"format": "Format",
"formatTxt": "Plain Text (.txt)",
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Print)",
"generate": "Generate",
"generating": "Generating...",
"noSelection": "Select at least one medication",
"filterByPerson": "Report for",
"allPeople": "Everyone",
"docTitle": "Medication Report",
"docGenerated": "Generated on",
"docGeneral": "General",
"docCommercialName": "Commercial Name",
"docGenericName": "Generic Name",
"docTakenBy": "Taken by",
"docStartDate": "Start Date",
"docObsoleteSince": "Obsolete Since",
"docStatus": "Status",
"docStatusActive": "Active",
"docStatusObsolete": "Obsolete",
"docPackage": "Package",
"docPackageType": "Package Type",
"docBlister": "Blister Pack",
"docBottle": "Pill Bottle",
"docPacks": "Packs",
"docBlistersPerPack": "Blisters per pack",
"docPillsPerBlister": "Pills per blister",
"docTotalCapacity": "Total capacity",
"docCurrentStock": "Current stock",
"docLoosePills": "Loose pills",
"docDose": "Dose",
"docDosePerPill": "Dose per pill",
"docExpiryDate": "Expiry Date",
"docNotes": "Notes",
"docIntakeSchedule": "Intake Schedule",
"docIntakeEntry": "{{usage}} pill(s) every {{every}} day(s) from {{start}}",
"docIntakeTakenBy": "taken by {{person}}",
"docIntakeReminder": "reminder enabled",
"docPrescription": "Prescription",
"docAuthorizedRefills": "Authorized refills",
"docRemainingRefills": "Remaining refills",
"docPrescriptionExpiry": "Prescription expiry",
"docIntakeHistory": "Intake History",
"docDosesTaken": "Doses taken",
"docDosesTakenAutomatic": "Automatically taken",
"docDosesDismissed": "Doses dismissed",
"docFirstDose": "First dose",
"docLastDose": "Last dose",
"docRefillHistory": "Refill History",
"docRefillEntry": "{{date}}: +{{packs}} packs, +{{loose}} pills",
"docRefillPrescription": "(prescription refill)",
"docNoRefills": "No refills recorded",
"docNoDoses": "No doses recorded",
"docPrintInstruction": "Use your browser's Print function (Ctrl+P / ⌘P) to save as PDF."
} }
} }
+4
View File
@@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
import "./styles/modals-base.css";
import "./styles/share-dialog.css";
import "./styles/medication-workflows.css";
import "./styles/schedule-mobile-edit.css";
import "./i18n"; import "./i18n";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
+270 -191
View File
@@ -1,59 +1,21 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Bell, 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 type { Coverage } from "../types"; import { useModalHistory } from "../hooks";
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 {
// Helper for user-specific localStorage keys formatFullBlisters,
export function userStorageKey(userId: number | undefined, key: string): string { formatOpenBlisterAndLoose,
return userId ? `user_${userId}_${key}` : key; getBlisterStock,
} getMedTotal,
getReminderStatusData,
// Helper function to calculate blister stock userStorageKey,
export function getBlisterStock( } from "./dashboard-helpers";
totalPills: number,
pillsPerBlister: number,
_looseTablets: number,
_originalTotal: number
) {
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
const openBlisterPills = totalPills % pillsPerBlister;
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
}
// Helper to format full blisters
export function formatFullBlisters(count: number, t: (key: string) => string): string {
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
}
// Helper to format open blister and loose pills
export function formatOpenBlisterAndLoose(
openBlisterPills: number,
loosePills: number,
pillsPerBlister: number,
t: (key: string) => string
): string {
if (openBlisterPills === 0 && loosePills === 0) return "-";
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
}
// Get total pills for a medication (packageType-aware)
export function getMedTotal(med: {
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
stockAdjustment?: number | null;
packageType?: string;
}): number {
if (med.packageType === "bottle") {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
}
// Notification bell SVG icon (no emoji) // Notification bell SVG icon (no emoji)
function NotificationBellIcon() { function NotificationBellIcon() {
@@ -75,108 +37,6 @@ function NotificationBellIcon() {
); );
} }
// Get structured reminder status data
export function getReminderStatusData(
reminderDaysBefore: number,
lowStockDays: number,
_allLowCoverage: Coverage[],
allCoverage: Coverage[],
lastAutoEmailSent: string | null,
lastNotificationType: string | null,
_lastNotificationChannel: string | null,
lastReminderMedName: string | null,
lastReminderTakenBy: string | null,
lastStockReminderSent: string | null,
_lastStockReminderChannel: string | null,
lastStockReminderMedNames: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): {
status: { text: string; className: string };
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastStockSent: { date: string; medNames: string | null } | null;
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
} {
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
for (const c of allCoverage) {
if (c.medsLeft <= 0) {
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
continue;
}
if (c.daysLeft === null) continue;
const roundedDaysLeft = Math.round(c.daysLeft);
const isCritical = c.daysLeft <= reminderDaysBefore;
const isLow = c.daysLeft < lowStockDays;
if (!isCritical && !isLow) continue;
const existing = lowStockMap.get(c.name);
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
}
}
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
// Determine status
let status: { text: string; className: string };
if (criticalCount > 0) {
status = {
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
className: "danger",
};
} else if (lowCount > 0) {
status = {
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
className: "warning",
};
} else {
status = {
text: t("dashboard.reminders.allOk"),
className: "success",
};
}
// Parse last stock reminder sent info (from dedicated stock tracking columns)
let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastStockSent = {
date: formattedDate,
medNames: lastStockReminderMedNames,
};
}
// Parse last intake reminder sent info (from intake tracking columns)
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastIntakeSent = {
date: formattedDate,
medName: lastReminderMedName,
takenBy: lastReminderTakenBy,
};
}
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
}
export function DashboardPage() { export function DashboardPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
@@ -205,6 +65,7 @@ export function DashboardPage() {
missedPastDoseIds, missedPastDoseIds,
getDayStockStatus, getDayStockStatus,
getDoseId, getDoseId,
isDoseTakenAutomatically,
showClearMissedConfirm, showClearMissedConfirm,
setShowClearMissedConfirm, setShowClearMissedConfirm,
clearingMissed, clearingMissed,
@@ -217,6 +78,8 @@ export function DashboardPage() {
loadSettings, loadSettings,
} = useAppContext(); } = useAppContext();
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
// Get structured reminder data // Get structured reminder data
const reminderData = getReminderStatusData( const reminderData = getReminderStatusData(
settings.reminderDaysBefore, settings.reminderDaysBefore,
@@ -262,6 +125,7 @@ export function DashboardPage() {
.sort((a, b) => a.remainingRefills - b.remainingRefills); .sort((a, b) => a.remainingRefills - b.remainingRefills);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled; const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
const showOnlyToday = settings.upcomingTodayOnly;
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length; const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
const prescriptionStatus = const prescriptionStatus =
@@ -401,6 +265,11 @@ export function DashboardPage() {
<span <span
className={`med-link clickable ${textClass}`} className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)} onClick={() => medication && openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (medication) openMedDetail(medication);
}
}}
> >
{med.name} {med.name}
</span> </span>
@@ -430,6 +299,11 @@ export function DashboardPage() {
<span <span
className={`med-link clickable ${textClass}`} className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)} onClick={() => medication && openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (medication) openMedDetail(medication);
}
}}
> >
{med.name} {med.name}
</span> </span>
@@ -453,7 +327,13 @@ export function DashboardPage() {
<span key={name}> <span key={name}>
{idx > 0 && ", "} {idx > 0 && ", "}
{medication ? ( {medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}> <span
className="med-link clickable"
onClick={() => openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
}}
>
{name} {name}
</span> </span>
) : ( ) : (
@@ -475,7 +355,13 @@ export function DashboardPage() {
(() => { (() => {
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName); const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
return medication ? ( return medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}> <span
className="med-link clickable"
onClick={() => openMedDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
}}
>
{reminderData.lastIntakeSent!.medName} {reminderData.lastIntakeSent!.medName}
</span> </span>
) : ( ) : (
@@ -553,7 +439,15 @@ export function DashboardPage() {
return ( return (
<span key={c.name}> <span key={c.name}>
{idx > 0 && ", "} {idx > 0 && ", "}
<span className={`med-link clickable ${textClass}`} onClick={() => med && openMedDetail(med)}> <span
className={`med-link clickable ${textClass}`}
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
{c.name} {c.name}
</span> </span>
<span className={`reminder-days-left ${textClass}`}> <span className={`reminder-days-left ${textClass}`}>
@@ -571,7 +465,10 @@ export function DashboardPage() {
</section> </section>
)} )}
<section className="grid"> <div
className={`dashboard-main-sections${settings.swapDashboardMainSections ? " dashboard-main-sections-swapped" : ""}`}
>
<section className="grid dashboard-overview-section">
<article className="card"> <article className="card">
<div className="card-head"> <div className="card-head">
<h2>{t("dashboard.overview.title")}</h2> <h2>{t("dashboard.overview.title")}</h2>
@@ -603,12 +500,45 @@ export function DashboardPage() {
med ? getMedTotal(med) : Math.round(row.medsLeft) med ? getMedTotal(med) : Math.round(row.medsLeft)
); );
return ( return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}> <div
key={row.name}
className="table-row clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<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">
<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} /> <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">{row.name}</span> <span className="med-name-text">
{row.name}
{med?.notes && (
<>
{" "}
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
<NotebookPen size={13} aria-hidden="true" />
</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">
{med.takenBy.map((person) => ( {med.takenBy.map((person) => (
@@ -619,22 +549,28 @@ export function DashboardPage() {
e.stopPropagation(); e.stopPropagation();
openUserFilter(person); openUserFilter(person);
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openUserFilter(person);
}
}}
> >
{person} {person}
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"} {med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && (
<Bell
size={11}
aria-hidden="true"
className="blister-reminder-icon"
style={{ display: "inline", verticalAlign: "middle", marginLeft: "2px" }}
/>
)}
</span> </span>
))} ))}
</span> </span>
)} )}
</span> </span>
</span> </span>
{med?.notes && (
<span className="med-icons">
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
</span>
)}
</span> </span>
<span data-label={t("table.stock")} className={textClass}> <span data-label={t("table.stock")} className={textClass}>
{med?.packageType === "bottle" {med?.packageType === "bottle"
@@ -677,16 +613,11 @@ export function DashboardPage() {
</article> </article>
</section> </section>
<section className="grid"> <section className="grid dashboard-schedules-section">
<article className="card"> <article className="card">
<div className="card-head"> <div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2> <h2>{t("dashboard.schedules.title")}</h2>
<div className="card-head-actions"> <div className="card-head-actions">
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button className="ghost share-btn" onClick={openShareDialog} title={t("share.button")}>
🔗 {t("share.button")}
</button>
)}
<select <select
className="schedule-days-select" className="schedule-days-select"
value={scheduleDays} value={scheduleDays}
@@ -700,11 +631,22 @@ export function DashboardPage() {
<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>
</select> </select>
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button
className="ghost share-btn icon-only tooltip-trigger"
onClick={openShareDialog}
aria-label={t("share.button")}
data-tooltip={t("share.button")}
>
<Share2 size={18} aria-hidden="true" />
</button>
)}
</div> </div>
</div> </div>
<div className="timeline"> <div className="timeline">
{/* Past days (when expanded) — rendered above toggle */} {/* Past days (when expanded) — rendered above toggle */}
{showPastDays && {!showOnlyToday &&
showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling) // Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
@@ -737,7 +679,7 @@ export function DashboardPage() {
const isAutoCollapsed = true; // Past days are always auto-collapsed const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds); const _worstStatus = getDayStockStatus(day.meds);
return ( return (
<div <div
@@ -747,6 +689,9 @@ export function DashboardPage() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -782,16 +727,35 @@ export function DashboardPage() {
const itemDoseIds = expandDoseIds(item.doses); const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main"> <div className="time-main">
<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 && openScheduleLightbox(`/api/images/${med.imageUrl}`)} onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
> >
<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 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>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -808,28 +772,36 @@ export function DashboardPage() {
<div key={dose.id} className="dose-item past"> <div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
{dose.intakeRemindersEnabled && ( {dose.intakeRemindersEnabled && (
<span <span
className="reminder-icon info-tooltip" className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")} data-tooltip={t("tooltips.intakeReminders")}
> >
🔔 <Bell size={13} aria-hidden="true" />
</span> </span>
)} )}
<div className="dose-checks"> <div className="dose-checks">
{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 && (
<span <span
className="person-name clickable" className="person-name clickable"
onClick={() => openUserFilter(person)} onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
> >
{person} {person}
</span> </span>
@@ -840,6 +812,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>
) : ( ) : (
@@ -849,7 +829,8 @@ export function DashboardPage() {
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={isEmpty} disabled={isEmpty}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -867,7 +848,8 @@ export function DashboardPage() {
); );
})} })}
{/* Past days toggle */} {/* Past days toggle */}
{pastDays.length > 0 && {!showOnlyToday &&
pastDays.length > 0 &&
(() => { (() => {
const missedCount = missedPastDoseIds.length; const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses))); const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
@@ -886,6 +868,19 @@ export function DashboardPage() {
}, 50); }, 50);
} }
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}
}}
> >
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label"> <span className="past-days-label">
@@ -960,6 +955,9 @@ export function DashboardPage() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -989,16 +987,35 @@ export function DashboardPage() {
const itemDoseIds = expandDoseIds(item.doses); const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main"> <div className="time-main">
<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 && openScheduleLightbox(`/api/images/${med.imageUrl}`)} onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
> >
<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 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>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1019,28 +1036,36 @@ export function DashboardPage() {
> >
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
{dose.intakeRemindersEnabled && ( {dose.intakeRemindersEnabled && (
<span <span
className="reminder-icon info-tooltip" className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")} data-tooltip={t("tooltips.intakeReminders")}
> >
🔔 <Bell size={13} aria-hidden="true" />
</span> </span>
)} )}
<div className="dose-checks"> <div className="dose-checks">
{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 && (
<span <span
className="person-name clickable" className="person-name clickable"
onClick={() => openUserFilter(person)} onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
> >
{person} {person}
</span> </span>
@@ -1051,6 +1076,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>
) : ( ) : (
@@ -1060,7 +1093,8 @@ export function DashboardPage() {
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={isEmpty} disabled={isEmpty}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -1078,7 +1112,8 @@ export function DashboardPage() {
); );
})()} })()}
{/* Future days toggle */} {/* Future days toggle */}
{futureDays.length > 0 && {!showOnlyToday &&
futureDays.length > 0 &&
(() => { (() => {
const totalFutureDoses = futureDays.flatMap((d) => const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => d.meds.flatMap((m) =>
@@ -1093,6 +1128,9 @@ export function DashboardPage() {
<div <div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`} className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)} onClick={() => setShowFutureDays(!showFutureDays)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
}}
> >
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span> <span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label"> <span className="future-days-label">
@@ -1113,7 +1151,8 @@ export function DashboardPage() {
); );
})()} })()}
{/* Future days */} {/* Future days */}
{showFutureDays && {!showOnlyToday &&
showFutureDays &&
futureDays.map((day) => { futureDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses)); const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
@@ -1147,6 +1186,9 @@ export function DashboardPage() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -1176,16 +1218,35 @@ export function DashboardPage() {
const itemDoseIds = expandDoseIds(item.doses); const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main"> <div className="time-main">
<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 && openScheduleLightbox(`/api/images/${med.imageUrl}`)} onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
}
}}
> >
<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 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>}
</div>
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span> <span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1202,28 +1263,36 @@ export function DashboardPage() {
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && </span>
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} {med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
{dose.intakeRemindersEnabled && ( {dose.intakeRemindersEnabled && (
<span <span
className="reminder-icon info-tooltip" className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")} data-tooltip={t("tooltips.intakeReminders")}
> >
🔔 <Bell size={13} aria-hidden="true" />
</span> </span>
)} )}
<div className="dose-checks"> <div className="dose-checks">
{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 && (
<span <span
className="person-name clickable" className="person-name clickable"
onClick={() => openUserFilter(person)} onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
> >
{person} {person}
</span> </span>
@@ -1234,6 +1303,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>
) : ( ) : (
@@ -1243,7 +1320,8 @@ export function DashboardPage() {
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
disabled={true} disabled={true}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -1263,6 +1341,7 @@ export function DashboardPage() {
</div> </div>
</article> </article>
</section> </section>
</div>
{/* Clear Missed Doses Confirmation Modal */} {/* Clear Missed Doses Confirmation Modal */}
{showClearMissedConfirm && ( {showClearMissedConfirm && (
+359 -233
View File
@@ -1,9 +1,14 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal } from "../components"; import { useSearchParams } from "react-router-dom";
import { ConfirmModal, DateInput, 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, 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, getPackageSize } from "../types"; import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters"; import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
@@ -16,6 +21,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete"; const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
export function MedicationsPage() { export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const { const {
@@ -28,14 +34,6 @@ export function MedicationsPage() {
deleteMedImage, deleteMedImage,
uploadingImage, uploadingImage,
existingPeople, existingPeople,
refillPacks,
setRefillPacks,
refillLoose,
setRefillLoose,
usePrescriptionRefill,
setUsePrescriptionRefill,
refillSaving,
submitRefill,
coverageByMed, coverageByMed,
} = useAppContext(); } = useAppContext();
@@ -72,9 +70,16 @@ export function MedicationsPage() {
// View mode: grid (default) or form (edit/new) // View mode: grid (default) or form (edit/new)
const [viewMode, setViewMode] = useState<"grid" | "form">("grid"); const [viewMode, setViewMode] = useState<"grid" | "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");
// 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(false);
const showEditModalRef = useRef(false);
useEffect(() => {
showEditModalRef.current = showEditModal;
}, [showEditModal]);
const processedEditMedIdRef = useRef<string | null>(null);
const hasDesktopFormHistoryState = useRef(false);
// Sync formChanged state to the global context for navigation blocking // Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges(); const { setHasUnsavedChanges } = useUnsavedChanges();
@@ -96,6 +101,18 @@ export function MedicationsPage() {
} }
}, [formChanged, showEditModal]); }, [formChanged, showEditModal]);
// Push a history state when desktop form is open so browser back returns to grid view.
useEffect(() => {
const isDesktop = window.innerWidth > 768;
if (isDesktop && viewMode === "form" && !showEditModal && !hasDesktopFormHistoryState.current) {
window.history.pushState({ desktopForm: true }, "");
hasDesktopFormHistoryState.current = true;
}
if ((viewMode === "grid" || showEditModal) && hasDesktopFormHistoryState.current) {
hasDesktopFormHistoryState.current = false;
}
}, [viewMode, showEditModal]);
// Image state for new medications // Image state for new medications
const [pendingImage, setPendingImage] = useState<File | null>(null); const [pendingImage, setPendingImage] = useState<File | null>(null);
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null); const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
@@ -113,6 +130,9 @@ export function MedicationsPage() {
const [allMeds, setAllMeds] = useState<Medication[]>(meds); const [allMeds, setAllMeds] = useState<Medication[]>(meds);
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);
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
const [showNameValidation, setShowNameValidation] = useState(false);
useEffect(() => { useEffect(() => {
const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY)); const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY));
@@ -153,8 +173,7 @@ export function MedicationsPage() {
const packCount = Number(form.packCount) || 0; const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1; const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0; return packCount * blistersPerPack * pillsPerBlister;
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]); }, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
const dateConsistencyError = useMemo(() => { const dateConsistencyError = useMemo(() => {
@@ -170,8 +189,22 @@ export function MedicationsPage() {
}); });
}, [form.medicationStartDate, form.intakes, t]); }, [form.medicationStartDate, form.intakes, t]);
const clearEditMedIdParam = useCallback(() => {
setSearchParams(
(prevParams) => {
if (!prevParams.has("editMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId");
return nextParams;
},
{ replace: true }
);
}, [setSearchParams]);
// 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" }, "");
} }
@@ -185,6 +218,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
clearEditMedIdParam();
// Mark as confirmed to avoid double confirmation in popstate handler // Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true; closeConfirmedRef.current = true;
window.history.back(); window.history.back();
@@ -207,6 +241,7 @@ export function MedicationsPage() {
setReadOnlyView(false); setReadOnlyView(false);
pendingAction(); pendingAction();
} else if (source === "mobile-edit" && showEditModal) { } else if (source === "mobile-edit" && showEditModal) {
clearEditMedIdParam();
setShowEditModal(false); setShowEditModal(false);
resetForm(); resetForm();
setReadOnlyView(false); setReadOnlyView(false);
@@ -229,12 +264,15 @@ export function MedicationsPage() {
// Helper to reset form and clear history state // Helper to reset form and clear history state
function handleResetForm() { function handleResetForm() {
hasDesktopFormHistoryState.current = false;
if (hasUnsavedHistoryState.current) { if (hasUnsavedHistoryState.current) {
hasUnsavedHistoryState.current = false; hasUnsavedHistoryState.current = false;
// Go back to remove the unsaved changes history entry // Go back to remove the unsaved changes history entry
window.history.back(); window.history.back();
} }
resetForm(); resetForm();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false); setReadOnlyView(false);
setViewMode("grid"); setViewMode("grid");
} }
@@ -296,12 +334,6 @@ export function MedicationsPage() {
window.history.back(); window.history.back();
} }
// Handle submit refill
async function handleSubmitRefill(medId: number) {
await submitRefill(medId, editingId, setForm, loadMeds, usePrescriptionRefill);
await loadAllMeds();
}
async function markMedicationObsolete(id: number) { async function markMedicationObsolete(id: number) {
try { try {
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" }); await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
@@ -330,6 +362,7 @@ export function MedicationsPage() {
e.preventDefault(); e.preventDefault();
if (readOnlyView) return; if (readOnlyView) return;
if (hasValidationErrors || dateConsistencyError) { if (hasValidationErrors || dateConsistencyError) {
setShowNameValidation(true);
// Scroll to first visible error so the user sees what's wrong // Scroll to first visible error so the user sees what's wrong
const firstError = document.querySelector(".field-error"); const firstError = document.querySelector(".field-error");
if (firstError) { if (firstError) {
@@ -439,6 +472,20 @@ export function MedicationsPage() {
// Reset form after successful save // Reset form after successful save
if (!editingId) { if (!editingId) {
const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768;
if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true;
clearEditMedIdParam();
setShowEditModal(false);
setReadOnlyView(false);
setActiveTab("general");
setViewMode("grid");
resetForm();
window.history.back();
setSaving(false);
return;
}
resetForm(); resetForm();
setViewMode("grid"); setViewMode("grid");
} else { } else {
@@ -456,6 +503,8 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes // Handle browser back button for modals and unsaved changes
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
// Obsolete confirmation is open — dismiss it and stay where we are // Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) { if (showObsoleteConfirm) {
setShowObsoleteConfirm(false); setShowObsoleteConfirm(false);
@@ -473,6 +522,11 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation // If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) { if (closeConfirmedRef.current) {
closeConfirmedRef.current = false; closeConfirmedRef.current = false;
if (currentEditMedId) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
processedEditMedIdRef.current = currentEditMedId;
clearEditMedIdParam();
}
if (showEditModal) { if (showEditModal) {
setShowEditModal(false); setShowEditModal(false);
resetForm(); resetForm();
@@ -491,11 +545,33 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
if (currentEditMedId) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
processedEditMedIdRef.current = currentEditMedId;
}
clearEditMedIdParam();
setShowEditModal(false); setShowEditModal(false);
resetForm(); resetForm();
return; return;
} }
// Handle desktop form: browser back should return to medication overview grid.
if (viewMode === "form" && hasDesktopFormHistoryState.current) {
if (formChanged) {
window.history.pushState({ desktopForm: true }, "");
setUnsavedConfirmSource("desktop-form");
setShowUnsavedConfirm(true);
return;
}
hasDesktopFormHistoryState.current = false;
resetForm();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false);
setViewMode("grid");
return;
}
// Handle desktop form with unsaved changes // Handle desktop form with unsaved changes
if (formChanged && hasUnsavedHistoryState.current) { if (formChanged && hasUnsavedHistoryState.current) {
// Re-push history state to stay on page // Re-push history state to stay on page
@@ -507,7 +583,7 @@ export function MedicationsPage() {
}; };
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState);
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, formChanged, resetForm]); }, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
// Close modal on Escape key // Close modal on Escape key
useEffect(() => { useEffect(() => {
@@ -520,10 +596,10 @@ export function MedicationsPage() {
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [showEditModal, closeEditModal]); }, [showEditModal, closeEditModal]);
// Handle edit button click - open modal on mobile, switch to form on desktop
function handleEditClick(med: Medication) { function handleEditClick(med: Medication) {
if (formChanged) { if (formChanged) {
pendingActionRef.current = () => { pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(false); setReadOnlyView(false);
startEdit(med, openEditModal); startEdit(med, openEditModal);
setViewMode("form"); setViewMode("form");
@@ -532,7 +608,9 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
setShowNameValidation(false);
setReadOnlyView(false); setReadOnlyView(false);
setActiveTab("general");
startEdit(med, openEditModal); startEdit(med, openEditModal);
setViewMode("form"); setViewMode("form");
} }
@@ -540,6 +618,7 @@ export function MedicationsPage() {
function handleViewClick(med: Medication) { function handleViewClick(med: Medication) {
if (formChanged) { if (formChanged) {
pendingActionRef.current = () => { pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(true); setReadOnlyView(true);
startEdit(med, openEditModal); startEdit(med, openEditModal);
setViewMode("form"); setViewMode("form");
@@ -548,7 +627,9 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true); setShowUnsavedConfirm(true);
return; return;
} }
setShowNameValidation(false);
setReadOnlyView(true); setReadOnlyView(true);
setActiveTab("general");
startEdit(med, openEditModal); startEdit(med, openEditModal);
setViewMode("form"); setViewMode("form");
} }
@@ -557,6 +638,7 @@ export function MedicationsPage() {
if (formChanged) { if (formChanged) {
pendingActionRef.current = () => { pendingActionRef.current = () => {
resetForm(); resetForm();
setShowNameValidation(false);
setReadOnlyView(false); setReadOnlyView(false);
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
openEditModal(); openEditModal();
@@ -569,6 +651,7 @@ export function MedicationsPage() {
return; return;
} }
resetForm(); resetForm();
setShowNameValidation(false);
setReadOnlyView(false); setReadOnlyView(false);
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
openEditModal(); openEditModal();
@@ -593,6 +676,31 @@ export function MedicationsPage() {
return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)]; return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)];
}, [activeMeds, editingId]); }, [activeMeds, editingId]);
useEffect(() => {
const editMedId = searchParams.get("editMedId");
if (!editMedId) {
processedEditMedIdRef.current = null;
return;
}
if (processedEditMedIdRef.current === editMedId) return;
const parsedMedId = Number.parseInt(editMedId, 10);
if (Number.isNaN(parsedMedId)) return;
const medicationToEdit = allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return;
processedEditMedIdRef.current = editMedId;
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
startEdit(medicationToEdit, openEditModal);
setViewMode("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
setSearchParams(nextParams, { replace: true });
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => { const selectedMedication = useMemo(() => {
if (!editingId) return null; if (!editingId) return null;
return allMeds.find((med) => med.id === editingId) ?? null; return allMeds.find((med) => med.id === editingId) ?? null;
@@ -604,9 +712,14 @@ export function MedicationsPage() {
<article className="card"> <article className="card">
<div className="card-head"> <div className="card-head">
<h2>{t("medications.list.title")}</h2> <h2>{t("medications.list.title")}</h2>
<div className="card-head-actions">
<button type="button" className="btn primary small" onClick={handleNewEntryClick}> <button type="button" className="btn primary small" onClick={handleNewEntryClick}>
+ {t("form.newEntry")} + {t("form.newEntry")}
</button> </button>
<button type="button" className="btn ghost small" onClick={() => setShowReportModal(true)}>
{t("report.button")}
</button>
</div>
</div> </div>
<div className="med-groups"> <div className="med-groups">
<div className="med-group med-group-active"> <div className="med-group med-group-active">
@@ -621,6 +734,11 @@ export function MedicationsPage() {
onClick={() => onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
} }
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
}
}}
> >
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" /> <MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
</span> </span>
@@ -630,17 +748,27 @@ export function MedicationsPage() {
</div> </div>
</div> </div>
<div className="med-actions"> <div className="med-actions">
<button className="danger" onClick={() => requestDeleteMed(med)}> {editingId !== med.id && (
{t("common.delete")} <button
className="info icon-only tooltip-trigger"
onClick={() => handleEditClick(med)}
aria-label={t("common.edit")}
data-tooltip={t("common.edit")}
>
<Pencil size={18} aria-hidden="true" />
</button>
)}
<button
className="danger icon-only tooltip-trigger"
onClick={() => requestDeleteMed(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button> </button>
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}> <button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
{t("medications.list.markObsolete")} {t("medications.list.markObsolete")}
</button> </button>
{editingId !== med.id && (
<button className="info" onClick={() => handleEditClick(med)}>
{t("common.edit")}
</button>
)}
</div> </div>
<div className="med-details"> <div className="med-details">
<span> <span>
@@ -697,14 +825,14 @@ export function MedicationsPage() {
<div className="blister-list"> <div className="blister-list">
{(med.intakes ?? med.blisters).map((s, idx) => ( {(med.intakes ?? med.blisters).map((s, idx) => (
<div key={`${med.id}-${idx}`} className="blister-row-simple"> <div key={`${med.id}-${idx}`} className="blister-row-simple">
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} · {t("form.blisters.every")}{" "} {s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
{s.every} {s.every === 1 ? t("common.day") : t("common.days")} · {t("form.blisters.from")}{" "} {s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
{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.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && ( {"intakeRemindersEnabled" in s && s.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" />
</span> </span>
)} )}
</div> </div>
@@ -738,6 +866,12 @@ export function MedicationsPage() {
onClick={() => onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
} }
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl)
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
}
}}
> >
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" /> <MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
</span> </span>
@@ -747,15 +881,25 @@ export function MedicationsPage() {
</div> </div>
</div> </div>
<div className="med-actions"> <div className="med-actions">
<button className="danger" onClick={() => requestDeleteMed(med)}> <button
{t("common.delete")} className="info icon-only tooltip-trigger"
onClick={() => handleViewClick(med)}
aria-label={t("common.view")}
data-tooltip={t("common.view")}
>
<Eye size={18} aria-hidden="true" />
</button>
<button
className="danger icon-only tooltip-trigger"
onClick={() => requestDeleteMed(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button> </button>
<button className="success" onClick={() => reactivateMedication(med.id)}> <button className="success" onClick={() => reactivateMedication(med.id)}>
{t("medications.list.reactivate")} {t("medications.list.reactivate")}
</button> </button>
<button className="info" onClick={() => handleViewClick(med)}>
{t("common.view")}
</button>
</div> </div>
<div className="med-details"> <div className="med-details">
{med.medicationStartDate && ( {med.medicationStartDate && (
@@ -787,35 +931,80 @@ export function MedicationsPage() {
{t("common.back")} {t("common.back")}
</button> </button>
{editingId ? ( {editingId ? (
<>
<MedicationAvatar
name={selectedMedication?.name || ""}
imageUrl={selectedMedication?.imageUrl}
size="md"
/>
<h2> <h2>
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name} {readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name}
</h2> </h2>
</>
) : ( ) : (
<h2>{t("form.newEntry")}</h2> <h2>{t("form.newEntry")}</h2>
)} )}
</div> </div>
</div> </div>
<form className="form-grid" onSubmit={saveMedication}> <form
className="form-grid"
onSubmit={saveMedication}
autoComplete="off"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
>
<div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")}>
<button
type="button"
role="tab"
aria-selected={activeTab === "general"}
className={`form-tab${activeTab === "general" ? " active" : ""}`}
onClick={() => setActiveTab("general")}
>
{t("form.sections.general")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "stock"}
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
onClick={() => setActiveTab("stock")}
>
{t("form.sections.stock")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "prescription"}
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
onClick={() => setActiveTab("prescription")}
>
{t("form.sections.prescription")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "schedule"}
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
onClick={() => setActiveTab("schedule")}
>
{t("form.sections.schedule")}
</button>
</div>
<fieldset className="readonly-fieldset" disabled={readOnlyView}> <fieldset className="readonly-fieldset" disabled={readOnlyView}>
<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={!readOnlyView && fieldErrors.name ? "has-error" : ""}> <label className={!readOnlyView && showNameValidation && fieldErrors.name ? "has-error" : ""}>
{t("form.commercialName")} {t("form.commercialName")}
<input <input
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => {
setShowNameValidation(true);
setForm({ ...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={!readOnlyView} required={!readOnlyView}
/> />
{!readOnlyView && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>} {!readOnlyView && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label> </label>
<label className={fieldErrors.genericName ? "has-error" : ""}> <label className={fieldErrors.genericName ? "has-error" : ""}>
{t("form.genericName")} {t("form.genericName")}
@@ -833,9 +1022,22 @@ export function MedicationsPage() {
value={form.medicationStartDate} value={form.medicationStartDate}
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)} onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
/> />
{!readOnlyView && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>} {!readOnlyView && dateConsistencyError && (
<span className="field-error">{dateConsistencyError}</span>
)}
</label> </label>
<label className={fieldErrors.takenBy ? "has-error" : ""}> <label>
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => handleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
{t("form.takenBy")} {t("form.takenBy")}
<div className="tag-input-container"> <div className="tag-input-container">
{form.takenBy.map((person) => ( {form.takenBy.map((person) => (
@@ -877,19 +1079,78 @@ export function MedicationsPage() {
</div> </div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>} {fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label> </label>
<label>
{t("form.packageType")}
<select
className="package-type-select"
value={form.packageType}
onChange={(e) => handleValueChange("packageType", e.target.value)}
>
<option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label>
</div> </div>
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{(() => {
if (editingId) {
const currentMed = meds.find((m) => m.id === editingId);
if (currentMed?.imageUrl) {
return (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => deleteMedImage(editingId)}
aria-label={t("form.removeImage")}
data-tooltip={t("form.removeImage")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
disabled={uploadingImage}
/>
);
}
if (pendingImagePreview) {
return (
<div className="image-preview">
<img src={pendingImagePreview} alt="Preview" />
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => {
setPendingImage(null);
setPendingImagePreview(null);
}}
aria-label={t("form.removeImage")}
data-tooltip={t("form.removeImage")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setPendingImage(file);
const reader = new FileReader();
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
reader.readAsDataURL(file);
}
}}
/>
);
})()}
</div>
</div>
{/* end general tab */}
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
<div className="full form-category"> <div className="full form-category">
<h4 className="form-category-title">{t("form.sections.stock")}</h4> <h4 className="form-category-title">{t("form.sections.stock")}</h4>
{form.packageType === "blister" ? ( {form.packageType === "blister" ? (
@@ -925,14 +1186,8 @@ export function MedicationsPage() {
/> />
</label> </label>
<label> <label>
{t("form.loosePills")} {t("form.total")}
<input <div className="static-value">{formatNumber(totalTablets)}</div>
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label> </label>
</> </>
) : ( ) : (
@@ -983,10 +1238,14 @@ export function MedicationsPage() {
</select> </select>
</div> </div>
</label> </label>
<label> {form.packageType === "bottle" && (
<div className="full stock-total-row">
<label className="stock-total-field">
{t("form.total")} {t("form.total")}
<div className="static-value">{formatNumber(totalTablets)}</div> <div className="static-value">{formatNumber(totalTablets)}</div>
</label> </label>
</div>
)}
<label> <label>
{t("form.expiryDate")} {t("form.expiryDate")}
<DateInput <DateInput
@@ -1011,14 +1270,19 @@ export function MedicationsPage() {
}} }}
/> />
{form.notes.length > 0 && ( {form.notes.length > 0 && (
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}> <span
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
>
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })} {t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
</span> </span>
)} )}
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>} {fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
</label> </label>
</div> </div>
</div>
{/* end stock tab */}
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
<div className="full form-category"> <div className="full form-category">
<h4 className="form-category-title">{t("form.sections.prescription")}</h4> <h4 className="form-category-title">{t("form.sections.prescription")}</h4>
<label className="full"> <label className="full">
@@ -1074,104 +1338,22 @@ export function MedicationsPage() {
</> </>
)} )}
</div> </div>
</div>
{/* end prescription tab */}
{!readOnlyView && ( <div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
<div className="full form-category refill-section">
<h4 className="form-category-title">{t("refill.title")}</h4>
{editingId ? (
<>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label className="full">
{t("refill.pillsToAdd")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<div className="refill-submit-row full">
<button
type="button"
className="success"
onClick={() => handleSubmitRefill(editingId)}
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
>
{refillSaving ? t("refill.adding") : t("refill.button")}
</button>
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
{form.prescriptionEnabled && (
<div className="refill-prescription-row full">
<label className="refill-prescription-toggle">
<input
type="checkbox"
checked={usePrescriptionRefill}
onChange={(e) => setUsePrescriptionRefill(e.target.checked)}
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
/>
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
</label>
<span className="refill-remaining-badge">
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
</span>
</div>
)}
</>
) : (
<p className="refill-unavailable">
{t("refill.saveFirst", "Save medication first to enable refill")}
</p>
)}
</div>
)}
<div className="full form-category intake-section"> <div className="full form-category intake-section">
<div className="form-category-header"> <div className="form-category-header">
<h4 className="form-category-title">{t("form.blisters.title")}</h4> <h4 className="form-category-title">{t("form.blisters.title")}</h4>
{!readOnlyView && ( {!readOnlyView && (
<button <button
type="button" type="button"
className="primary" className="primary icon-only tooltip-trigger"
onClick={() => addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)} onClick={() => addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
aria-label={t("form.blisters.addIntake")}
data-tooltip={t("form.blisters.addIntake")}
> >
+ {t("form.blisters.addIntake")} <Plus size={18} aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
@@ -1214,7 +1396,7 @@ export function MedicationsPage() {
/> />
</label> </label>
{form.takenBy.length === 0 ? null : ( {form.takenBy.length === 0 ? null : (
<label title={t("form.blisters.takenByTooltip")}> <label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
{t("form.blisters.takenByIntake")} {t("form.blisters.takenByIntake")}
<select <select
value={intake.takenBy} value={intake.takenBy}
@@ -1229,7 +1411,9 @@ export function MedicationsPage() {
</label> </label>
)} )}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}> <div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span>🔔</span> <span className="blister-reminder-icon">
<Bell size={14} aria-hidden="true" />
</span>
<label className="toggle-switch small"> <label className="toggle-switch small">
<input <input
type="checkbox" type="checkbox"
@@ -1241,78 +1425,25 @@ export function MedicationsPage() {
</div> </div>
</div> </div>
{!readOnlyView && form.intakes.length > 1 && ( {!readOnlyView && form.intakes.length > 1 && (
<button type="button" className="danger" onClick={() => removeIntake(idx)}> <button
{t("common.remove")} type="button"
className="danger icon-only tooltip-trigger"
onClick={() => removeIntake(idx)}
aria-label={t("common.remove")}
data-tooltip={t("common.remove")}
>
<Minus size={18} aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="full form-category image-section">
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
{(() => {
// When editing an existing medication
if (editingId) {
const currentMed = meds.find((m) => m.id === editingId);
if (currentMed?.imageUrl) {
return (
<div className="image-preview">
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>
{t("form.removeImage")}
</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
disabled={uploadingImage}
/>
);
}
// When creating a new medication
if (pendingImagePreview) {
return (
<div className="image-preview">
<img src={pendingImagePreview} alt="Preview" />
<button
type="button"
className="danger"
onClick={() => {
setPendingImage(null);
setPendingImagePreview(null);
}}
>
{t("form.removeImage")}
</button>
</div>
);
}
return (
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setPendingImage(file);
const reader = new FileReader();
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
reader.readAsDataURL(file);
}
}}
/>
);
})()}
</div> </div>
{/* end schedule tab */}
</fieldset> </fieldset>
<div className="full align-end gap"> <div className="full align-end gap">
<button type="button" className="ghost" onClick={handleDesktopFormLeave}> <button type="button" className="ghost" onClick={handleDesktopFormLeave}>
{readOnlyView ? t("common.close") : t("common.cancel")} {readOnlyView || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
</button> </button>
{!readOnlyView && ( {!readOnlyView && (
<button <button
@@ -1354,14 +1485,6 @@ export function MedicationsPage() {
onAddIntake={addIntake} onAddIntake={addIntake}
onRemoveIntake={removeIntake} onRemoveIntake={removeIntake}
onHandleValueChange={handleValueChange} onHandleValueChange={handleValueChange}
refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks}
refillLoose={refillLoose}
onRefillLooseChange={setRefillLoose}
usePrescriptionRefill={usePrescriptionRefill}
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
refillSaving={refillSaving}
onSubmitRefill={handleSubmitRefill}
meds={allMeds} meds={allMeds}
onUploadMedImage={uploadMedImage} onUploadMedImage={uploadMedImage}
onDeleteMedImage={deleteMedImage} onDeleteMedImage={deleteMedImage}
@@ -1397,7 +1520,7 @@ export function MedicationsPage() {
cancelLabel={t("common.cancel")} cancelLabel={t("common.cancel")}
onConfirm={handleConfirmMarkObsolete} onConfirm={handleConfirmMarkObsolete}
onCancel={handleCancelMarkObsolete} onCancel={handleCancelMarkObsolete}
confirmVariant="danger" confirmVariant="warning"
overlayClassName={showEditModal ? "nested-confirm" : undefined} overlayClassName={showEditModal ? "nested-confirm" : undefined}
/> />
)} )}
@@ -1420,6 +1543,9 @@ export function MedicationsPage() {
{lightboxImage && ( {lightboxImage && (
<Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={() => setLightboxImage(null)} /> <Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={() => setLightboxImage(null)} />
)} )}
{/* Report Modal */}
<ReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} medications={allMeds} />
</section> </section>
); );
} }
+16 -2
View File
@@ -1,3 +1,4 @@
/* 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";
@@ -168,6 +169,7 @@ export function PlannerPage() {
{t("planner.until")} {t("planner.until")}
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} /> <DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
</label> </label>
<div className="planner-checkbox-row">
<label className="planner-checkbox"> <label className="planner-checkbox">
<input <input
type="checkbox" type="checkbox"
@@ -175,10 +177,11 @@ export function PlannerPage() {
onChange={(e) => setIncludeUntilStart(e.target.checked)} onChange={(e) => setIncludeUntilStart(e.target.checked)}
/> />
{t("planner.includeUntilStart")} {t("planner.includeUntilStart")}
</label>
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}> <span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
</span> </span>
</label> </div>
<div className="planner-actions"> <div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}> <button type="button" className="ghost" onClick={resetRange}>
{t("common.reset")} {t("common.reset")}
@@ -204,15 +207,26 @@ export function PlannerPage() {
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName); meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null; const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
return ( return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}> <div
key={row.medicationId}
className="table-row clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span data-label={t("planner.table.medication")} className="cell-with-avatar"> <span data-label={t("planner.table.medication")} className="cell-with-avatar">
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} /> <MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
{row.medicationName} {row.medicationName}
</span> </span>
<span data-label={t("planner.table.usage")}> <span data-label={t("planner.table.usage")}>
<span>
<strong>{row.plannerUsage}</strong>&nbsp; <strong>{row.plannerUsage}</strong>&nbsp;
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")} {row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
</span> </span>
</span>
<span data-label={t("planner.table.blisters")}> <span data-label={t("planner.table.blisters")}>
{row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`} {row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
</span> </span>
+63 -8
View File
@@ -1,3 +1,5 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components"; import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
@@ -65,6 +67,7 @@ export function SchedulePage() {
pastDays, pastDays,
futureDays, futureDays,
takenDoses, takenDoses,
isDoseTakenAutomatically,
dismissedDoses, dismissedDoses,
markDoseTaken, markDoseTaken,
undoDoseTaken, undoDoseTaken,
@@ -129,7 +132,7 @@ export function SchedulePage() {
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings); const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
return ( return (
<div <div
@@ -139,6 +142,9 @@ export function SchedulePage() {
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)} onClick={() => toggleDayCollapse(day.dateStr, true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
}}
title={isCollapsed ? t("common.expand") : t("common.collapse")} title={isCollapsed ? t("common.expand") : t("common.collapse")}
> >
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -189,27 +195,36 @@ export function SchedulePage() {
<div key={dose.id} className="dose-item past"> <div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} </span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>{" "} </span>{" "}
{dose.intakeRemindersEnabled && ( {dose.intakeRemindersEnabled && (
<span <span
className="reminder-icon info-tooltip" className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")} data-tooltip={t("tooltips.intakeReminders")}
> >
🔔 <Bell size={14} aria-hidden="true" />
</span> </span>
)}{" "} )}{" "}
<div className="dose-checks"> <div className="dose-checks">
{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 && (
<span <span
className="person-name clickable" className="person-name clickable"
onClick={() => openUserFilter(person)} onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
> >
{person} {person}
</span> </span>
@@ -220,6 +235,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>
) : ( ) : (
@@ -229,7 +252,8 @@ export function SchedulePage() {
disabled={isEmpty} disabled={isEmpty}
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
@@ -264,6 +288,19 @@ export function SchedulePage() {
}, 50); }, 50);
} }
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}
}}
> >
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label"> <span className="past-days-label">
@@ -329,21 +366,27 @@ export function SchedulePage() {
<div key={dose.id} className="dose-item"> <div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`} </span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span> </span>
{dose.intakeRemindersEnabled && ( {dose.intakeRemindersEnabled && (
<span <span
className="reminder-icon info-tooltip" className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")} data-tooltip={t("tooltips.intakeReminders")}
> >
🔔 <Bell size={14} aria-hidden="true" />
</span> </span>
)} )}
<div className="dose-checks"> <div className="dose-checks">
{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
@@ -351,7 +394,13 @@ export function SchedulePage() {
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
> >
{person && ( {person && (
<span className="person-name clickable" onClick={() => openUserFilter(person)}> <span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person} {person}
</span> </span>
)} )}
@@ -361,6 +410,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>
) : ( ) : (
@@ -370,7 +424,8 @@ export function SchedulePage() {
disabled={isEmpty} disabled={isEmpty}
title={t("dose.markAsTaken")} title={t("dose.markAsTaken")}
> >
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button> </button>
)} )}
</div> </div>
+111 -51
View File
@@ -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";
@@ -30,8 +31,10 @@ export function SettingsPage() {
handleImportConfirm, handleImportConfirm,
importResult, importResult,
setImportResult, setImportResult,
meds,
} = useAppContext(); } = useAppContext();
const hasExistingData = meds.length > 0;
return ( return (
<section className="grid"> <section className="grid">
{settingsLoading ? ( {settingsLoading ? (
@@ -43,7 +46,6 @@ export function SettingsPage() {
<div className="card-head"> <div className="card-head">
<h2>{t("settings.language.title")}</h2> <h2>{t("settings.language.title")}</h2>
</div> </div>
<div className="setting-section">
<label className="setting-row language-row"> <label className="setting-row language-row">
<span className="setting-label">{t("settings.language.select")}</span> <span className="setting-label">{t("settings.language.select")}</span>
<select <select
@@ -64,7 +66,6 @@ export function SettingsPage() {
<option value="de">🇩🇪 Deutsch</option> <option value="de">🇩🇪 Deutsch</option>
</select> </select>
</label> </label>
</div>
</article> </article>
{/* Notifications */} {/* Notifications */}
@@ -89,7 +90,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false} checked={settings.emailStockReminders}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled} disabled={!settings.emailEnabled}
/> />
@@ -100,9 +101,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={ checked={settings.shoutrrrStockReminders}
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled} disabled={!settings.shoutrrrEnabled}
/> />
@@ -116,7 +115,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false} checked={settings.emailIntakeReminders}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled} disabled={!settings.emailEnabled}
/> />
@@ -127,9 +126,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={ checked={settings.shoutrrrIntakeReminders}
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled} disabled={!settings.shoutrrrEnabled}
/> />
@@ -143,9 +140,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={ checked={settings.emailPrescriptionReminders}
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
}
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
disabled={!settings.emailEnabled} disabled={!settings.emailEnabled}
/> />
@@ -156,11 +151,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}> <label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input <input
type="checkbox" type="checkbox"
checked={ checked={settings.shoutrrrPrescriptionReminders}
settings.shoutrrrUrl && settings.shoutrrrEnabled
? settings.shoutrrrPrescriptionReminders
: false
}
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })} onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled} disabled={!settings.shoutrrrEnabled}
/> />
@@ -373,9 +364,16 @@ export function SettingsPage() {
{settings.emailEnabled && ( {settings.emailEnabled && (
<> <>
<div className="setting-group"> <div className="setting-group">
<label className="full"> <div className="full">
<span className="field-label">{t("settings.email.recipient")}</span> <span className="field-label">
<div className="input-with-tooltip"> {t("settings.email.recipient")}
<span
className="info-tooltip"
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
>
</span>
</span>
<input <input
type="email" type="email"
value={settings.notificationEmail} value={settings.notificationEmail}
@@ -384,14 +382,7 @@ export function SettingsPage() {
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$" pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
autoComplete="email" autoComplete="email"
/> />
<span
className="info-tooltip"
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
>
</span>
</div> </div>
</label>
</div> </div>
<div className="setting-actions"> <div className="setting-actions">
<button <button
@@ -442,23 +433,23 @@ export function SettingsPage() {
{settings.shoutrrrEnabled && ( {settings.shoutrrrEnabled && (
<> <>
<div className="setting-group"> <div className="setting-group">
<label className="full"> <div className="full">
<span className="field-label">{t("settings.push.url")}</span> <span className="field-label">
<div className="input-with-tooltip"> {t("settings.push.url")}
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t("settings.push.urlPlaceholder")}
/>
<span <span
className="info-tooltip" className="info-tooltip"
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`} data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
> >
</span> </span>
</span>
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t("settings.push.urlPlaceholder")}
/>
</div> </div>
</label>
</div> </div>
<div className="setting-actions"> <div className="setting-actions">
<button <button
@@ -606,7 +597,7 @@ export function SettingsPage() {
<h3>{t("settings.stock.thresholds")}</h3> <h3>{t("settings.stock.thresholds")}</h3>
</div> </div>
<div className="setting-group threshold-chips-group"> <div className="setting-group threshold-chips-group">
<label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}> <div className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label"> <span className="field-label threshold-chip-label">
<span className="status-chip small danger">{t("status.criticalStock")}</span> <span className="status-chip small danger">{t("status.criticalStock")}</span>
<span <span
@@ -616,7 +607,6 @@ export function SettingsPage() {
</span> </span>
</span> </span>
<div className="input-with-tooltip">
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -625,8 +615,7 @@ export function SettingsPage() {
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })} onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/> />
</div> </div>
</label> <div
<label
className={ className={
settings.lowStockDays <= settings.reminderDaysBefore || settings.lowStockDays <= settings.reminderDaysBefore ||
settings.lowStockDays >= settings.highStockDays settings.lowStockDays >= settings.highStockDays
@@ -643,7 +632,6 @@ export function SettingsPage() {
</span> </span>
</span> </span>
<div className="input-with-tooltip">
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -652,8 +640,7 @@ export function SettingsPage() {
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })} onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/> />
</div> </div>
</label> <div className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label"> <span className="field-label threshold-chip-label">
<span className="status-chip small high">{t("status.highStock")}</span> <span className="status-chip small high">{t("status.highStock")}</span>
<span <span
@@ -663,7 +650,6 @@ export function SettingsPage() {
</span> </span>
</span> </span>
<div className="input-with-tooltip">
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -672,15 +658,68 @@ export function SettingsPage() {
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })} onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/> />
</div> </div>
</label>
</div> </div>
{(settings.reminderDaysBefore >= settings.lowStockDays || {(settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays) && ( settings.lowStockDays >= settings.highStockDays) && (
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p> <p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
)} )}
</div> </div>
</article>
{/* General UI */}
<article className="card">
<div className="card-head">
<h2>{t("settings.timeline.title")}</h2>
</div>
<div className="setting-section"> <div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.dashboardSectionOrder")}</h3>
</div>
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.timeline.swapDashboardSections")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.swapDashboardSectionsDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.swapDashboardMainSections}
onChange={(e) => setSettings({ ...settings, swapDashboardMainSections: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.upcomingSection")}</h3>
</div>
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.timeline.upcomingTodayOnly")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.upcomingTodayOnlyDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.upcomingTodayOnly}
onChange={(e) => setSettings({ ...settings, upcomingTodayOnly: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.timeline.sharedSection")}</h3>
</div>
<div className="setting-row compact"> <div className="setting-row compact">
<div className="setting-label"> <div className="setting-label">
<span>{t("settings.stock.shareStockStatus")}</span> <span>{t("settings.stock.shareStockStatus")}</span>
@@ -697,6 +736,22 @@ export function SettingsPage() {
<span className="toggle-slider"></span> <span className="toggle-slider"></span>
</label> </label>
</div> </div>
<div className="setting-row compact" style={{ marginTop: "10px" }}>
<div className="setting-label">
<span>{t("settings.timeline.shareScheduleTodayOnly")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.timeline.shareScheduleTodayOnlyDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareScheduleTodayOnly}
onChange={(e) => setSettings({ ...settings, shareScheduleTodayOnly: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div> </div>
</article> </article>
@@ -734,6 +789,7 @@ export function SettingsPage() {
{t("exportImport.importSuccessDetails", { {t("exportImport.importSuccessDetails", {
medications: importResult.medications, medications: importResult.medications,
doses: importResult.doses, doses: importResult.doses,
refills: importResult.refills,
shares: importResult.shares, shares: importResult.shares,
})} })}
</span> </span>
@@ -741,6 +797,7 @@ export function SettingsPage() {
<button <button
type="button" type="button"
onClick={() => setImportResult(null)} onClick={() => setImportResult(null)}
aria-label={t("common.close")}
style={{ style={{
background: "none", background: "none",
border: "none", border: "none",
@@ -751,7 +808,6 @@ export function SettingsPage() {
color: "inherit", color: "inherit",
opacity: 0.7, opacity: 0.7,
}} }}
aria-label="Close"
> >
× ×
</button> </button>
@@ -806,21 +862,25 @@ export function SettingsPage() {
{/* Import Confirmation Modal */} {/* Import Confirmation Modal */}
{showImportConfirm && ( {showImportConfirm && (
<ConfirmModal <ConfirmModal
title={t("exportImport.confirmImport")} title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={ message={
hasExistingData ? (
<> <>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p> <p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p> <p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</> </>
) : (
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
)
} }
confirmLabel={t("exportImport.confirmButton")} confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
cancelLabel={t("exportImport.cancelButton")} cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm} onConfirm={handleImportConfirm}
onCancel={() => { onCancel={() => {
setShowImportConfirm(false); setShowImportConfirm(false);
setPendingImportData(null); setPendingImportData(null);
}} }}
confirmVariant="danger" confirmVariant={hasExistingData ? "danger" : "primary"}
/> />
)} )}
+147
View File
@@ -0,0 +1,147 @@
import type { Coverage } from "../types";
import { getMedTotal as getMedTotalFromTypes } from "../types";
import { splitCurrentBlisterStock } from "../utils/stock";
export function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
export function getBlisterStock(
totalPills: number,
pillsPerBlister: number,
looseTablets: number,
_originalTotal: number
) {
return splitCurrentBlisterStock(totalPills, pillsPerBlister, looseTablets);
}
export function formatFullBlisters(count: number, t: (key: string) => string): string {
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
}
export function formatOpenBlisterAndLoose(
openBlisterPills: number,
loosePills: number,
pillsPerBlister: number,
t: (key: string) => string
): string {
if (openBlisterPills > 0 && loosePills > 0) {
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")} + ${loosePills} ${t("modal.loosePills")}`;
}
if (openBlisterPills > 0) {
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
}
if (loosePills > 0) {
return `${loosePills} ${t("modal.loosePills")}`;
}
return "-";
}
export function getMedTotal(med: {
packCount: number;
blistersPerPack: number;
pillsPerBlister: number;
looseTablets: number;
stockAdjustment?: number | null;
packageType?: string;
}): number {
return getMedTotalFromTypes(med);
}
export function getReminderStatusData(
reminderDaysBefore: number,
lowStockDays: number,
_allLowCoverage: Coverage[],
allCoverage: Coverage[],
lastAutoEmailSent: string | null,
_lastNotificationType: string | null,
_lastNotificationChannel: string | null,
lastReminderMedName: string | null,
lastReminderTakenBy: string | null,
lastStockReminderSent: string | null,
_lastStockReminderChannel: string | null,
lastStockReminderMedNames: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): {
status: { text: string; className: string };
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastStockSent: { date: string; medNames: string | null } | null;
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
} {
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
for (const c of allCoverage) {
if (c.medsLeft <= 0) {
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
continue;
}
if (c.daysLeft === null) continue;
const roundedDaysLeft = Math.round(c.daysLeft);
const isCritical = c.daysLeft <= reminderDaysBefore;
const isLow = c.daysLeft < lowStockDays;
if (!isCritical && !isLow) continue;
const existing = lowStockMap.get(c.name);
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
}
}
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
let status: { text: string; className: string };
if (criticalCount > 0) {
status = {
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
className: "danger",
};
} else if (lowCount > 0) {
status = {
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
className: "warning",
};
} else {
status = {
text: t("dashboard.reminders.allOk"),
className: "success",
};
}
let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastStockSent = {
date: formattedDate,
medNames: lastStockReminderMedNames,
};
}
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastIntakeSent = {
date: formattedDate,
medName: lastReminderMedName,
takenBy: lastReminderTakenBy,
};
}
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
}
+976 -1052
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More