Compare commits

..

35 Commits

Author SHA1 Message Date
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
Daniel Volz 4b697374f6 feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements

- Add soft-archive (obsolete) for medications with dedicated section and toggle
- Add medication start date field with date picker and validation
- Add obsolete/reactivate API endpoints with proper auth
- Filter obsolete meds from schedule, coverage, planner, and notifications
- Improve UserFilterModal with intake schedules, stock badges, and click-to-open
- Improve dashboard taken-by badges with per-intake bell icons
- Add Escape key support to ConfirmModal and MobileEditModal
- Fix Lightbox close button positioning near image
- Add read-only mode support for MobileEditModal
- DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date)
- All user-facing text uses i18n keys (en + de)

* test: fix tests for obsolete medications and UI changes

- Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas
- Backend: add test medication inserts in planner tests for active-med filtering
- Frontend: update useMedications URL to include includeObsolete param
- Frontend: fix MobileEditModal selectors and validation assertions
- Frontend: add onClearUser prop to UserFilterModal test renders
- Frontend: fix MedicationsPage and DashboardPage test assertions
2026-02-15 23:23:38 +01:00
138 changed files with 13832 additions and 6416 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
When code changes (features or bug fixes) are complete:
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
```bash
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
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.**
### 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)
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)
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)
+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.
- **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.
- **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.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
@@ -67,8 +68,8 @@ cd frontend && npm run build
```bash
cd frontend && npm run test:e2e
cd frontend && npm run test:e2e -- --project=chromium
cd frontend && npm run test:e2e:ui
cd frontend && npm run test:e2e:headed
# Never use interactive UI/headed/report-server commands in agent runs.
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
```
## 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`.
- PR/release/CI orchestration belongs to `@release-manager`.
- Keep changes local, focused, and consistent with existing UI/API patterns.
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
- Remove obsolete code when re-implementing — never leave dead code behind.
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps:
- name: Move project item to Done
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+4 -1
View File
@@ -1,5 +1,8 @@
{
"vitest.root": "backend",
"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 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/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<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-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 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
- Display remaining days of supply
- Automatic calculation based on intake schedule
- Manual stock correction supports partial blisters and loose pills
### Medication Refill
- 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
- Daily, weekly, or custom intervals per medication
- Independent schedules for each medication
- Optional timeline filters for dashboard and shared schedule views
### Stock Alerts & Reminders
- 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
- 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
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
@@ -0,0 +1,2 @@
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
ALTER TABLE `medications` ADD `obsolete_at` integer;
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
+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
+21
View File
@@ -57,6 +57,27 @@
"when": 1770659669121,
"tag": "0007_add_share_stock_status",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1771160400000,
"tag": "0008_add_obsolete_medications",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1771164000000,
"tag": "0009_add_medication_start_date",
"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",
"version": "1.11.1",
"version": "1.14.1",
"private": true,
"type": "module",
"scripts": {
@@ -35,9 +35,9 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@biomejs/biome": "^2.4.1",
"@types/node": "^25.2.3",
"@types/nodemailer": "^6.4.21",
"@types/nodemailer": "^7.0.10",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18",
"drizzle-kit": "^0.31.9",
+2 -4
View File
@@ -1,5 +1,4 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql";
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
import {
ensureDataDirectory,
ensureDefaultUser,
getDataDir,
getDbPaths,
repairOrphanedDoseIds,
repairTrailingHyphenDoseIds,
@@ -65,8 +63,8 @@ let client: Client;
try {
client = createClient({ url });
log.debug(`[DB] Database client created successfully`);
} catch (err: any) {
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
} catch (err: unknown) {
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
log.error(`[DB] Database path: ${dbPath}`);
process.exit(1);
}
+34 -23
View File
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
writeFileSync(testFile, "test");
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
} catch (err: unknown) {
return { success: false, error: (err as Error).message };
}
}
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
} catch (err: unknown) {
// 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,
// or when tables were created before drizzle migrations were introduced
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
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 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`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added for intake automation auditability (manual vs automatic taken)
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
// Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets
@@ -119,6 +121,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
// Added for soft-archiving medications (without deleting history)
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
// Added for explicit medication lifecycle start date
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
@@ -135,6 +142,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Added for share stock visibility toggle
`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
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
@@ -153,10 +164,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of alterMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
} catch (e: unknown) {
// Silently ignore "duplicate column" errors - column already exists
if (!e.message?.includes("duplicate column")) {
errors.push(e.message);
if (!(e as Error).message?.includes("duplicate column")) {
errors.push((e as Error).message);
}
}
}
@@ -177,10 +188,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of createTableMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
} catch (e: unknown) {
// Silently ignore "table already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
if (!(e as Error).message?.includes("already exists")) {
errors.push((e as Error).message);
}
}
}
@@ -194,10 +205,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
for (const sql of createIndexMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
} catch (e: unknown) {
// Silently ignore "already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
if (!(e as Error).message?.includes("already exists")) {
errors.push((e as Error).message);
}
}
}
@@ -222,8 +233,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
return true; // Created
}
return false; // Already exists
} catch (e: any) {
console.error(`[DB] Error creating default user:`, e.message);
} catch (e: unknown) {
console.error(`[DB] Error creating default user:`, (e as Error).message);
return false;
}
}
@@ -250,8 +261,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
);
repaired = result.rowsAffected;
} catch (e: any) {
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
} catch (e: unknown) {
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
}
return { repaired, errors };
@@ -374,14 +385,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
args: [newDoseId, dose.id],
});
repaired++;
} catch (e: any) {
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
} catch (e: unknown) {
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
}
}
}
}
} catch (e: any) {
errors.push(`Repair failed: ${e.message}`);
} catch (e: unknown) {
errors.push(`Repair failed: ${(e as Error).message}`);
}
return { repaired, errors };
+2 -2
View File
@@ -41,8 +41,8 @@ export async function executeMigration(
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: any) {
errors.push(err.message);
} catch (err: unknown) {
errors.push((err as Error).message);
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,
language text NOT NULL DEFAULT 'en',
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_notification_type 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')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+8
View File
@@ -47,6 +47,9 @@ export const medications = sqliteTable("medications", {
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
medicationStartDate: text("medication_start_date").notNull().default(""),
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
@@ -97,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
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)
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
@@ -156,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
});
+3
View File
@@ -20,6 +20,7 @@ import { medicationRoutes } from "./routes/medications.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js";
import { reportRoutes } from "./routes/report.js";
import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.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(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
return app;
}
@@ -190,6 +192,7 @@ await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
const start = async () => {
try {
+5 -2
View File
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
id: user.id,
username: user.username,
};
} catch (err: any) {
} catch (err: unknown) {
// 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;
}
// JWT verification failed
+4
View File
@@ -56,6 +56,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
@@ -94,6 +95,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
return { success: true };
@@ -227,6 +229,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
@@ -270,6 +273,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
takenSource: "manual",
});
return { success: true };
+94 -6
View File
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.0";
const EXPORT_VERSION = "1.1";
// =============================================================================
// Zod Schemas for Import Validation
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
packCount: z.number().int().min(0).default(1),
blistersPerPack: 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),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"),
@@ -49,14 +50,18 @@ const medicationExportSchema = z.object({
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
isObsolete: z.boolean().default(false),
obsoleteAt: z.string().nullable().optional(),
prescriptionEnabled: z.boolean().default(false),
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
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
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
});
@@ -67,10 +72,19 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
});
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({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
@@ -103,9 +117,11 @@ const settingsExportSchema = z
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
expiryWarningDays: z.number().int().default(90),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareStockStatus: z.boolean().default(true),
})
.optional();
@@ -115,6 +131,7 @@ const importDataSchema = z.object({
includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
refillHistory: z.array(refillHistoryExportSchema).default([]),
settings: settingsExportSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
@@ -124,7 +141,7 @@ const importDataSchema = z.object({
// =============================================================================
// 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) {
return getAnonymousUserId();
}
@@ -282,6 +299,7 @@ export async function exportRoutes(app: FastifyInstance) {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
@@ -289,14 +307,18 @@ export async function exportRoutes(app: FastifyInstance) {
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
@@ -343,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
@@ -374,8 +397,10 @@ export async function exportRoutes(app: FastifyInstance) {
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
}
: undefined;
@@ -406,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
const exportData = {
version: EXPORT_VERSION,
@@ -413,12 +471,17 @@ export async function exportRoutes(app: FastifyInstance) {
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// 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-Disposition", `attachment; filename="${filename}"`);
@@ -469,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(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
@@ -511,10 +575,12 @@ export async function exportRoutes(app: FastifyInstance) {
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
intakesJson,
usageJson,
everyJson,
@@ -522,11 +588,14 @@ export async function exportRoutes(app: FastifyInstance) {
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null, // Will be set after image is saved
})
.returning();
@@ -558,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
@@ -585,8 +655,10 @@ export async function exportRoutes(app: FastifyInstance) {
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
});
}
@@ -604,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 {
success: true,
imported: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
settings: importData.settings ? 1 : 0,
shareLinks: importData.shareLinks.length,
},
+232 -71
View File
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js";
import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
@@ -32,6 +32,9 @@ const blisterSchema = z.object({
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
const medicationStartDateSchema = z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
.optional();
const medicationSchema = z
.object({
@@ -46,6 +49,7 @@ const medicationSchema = z
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().nonnegative().nullable().optional(),
doseUnit: doseUnitSchema,
medicationStartDate: medicationStartDateSchema,
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
prescriptionEnabled: z.boolean().default(false),
@@ -59,6 +63,19 @@ const medicationSchema = z
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
})
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
.refine(
(data) => {
const startDate = data.medicationStartDate ?? "";
if (!startDate) return true;
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
},
{
message: "Medication start date must be on or before all intake dates",
path: ["medicationStartDate"],
}
)
.refine(
(data) => {
if (!data.prescriptionEnabled) return true;
@@ -103,9 +120,13 @@ export async function medicationRoutes(app: FastifyInstance) {
return authUser.id;
}
app.get("/medications", async (request, reply) => {
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
const userId = await getUserId(request, reply);
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const includeObsolete = request.query.includeObsolete === "true";
const whereClause = includeObsolete
? eq(medications.userId, userId)
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
return rows.map((row) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
@@ -129,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
doseUnit: row.doseUnit ?? "mg",
medicationStartDate: row.medicationStartDate || null,
intakes, // New unified format with per-intake takenBy
// Legacy blisters format (for backward compat with frontend during transition)
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
@@ -136,6 +158,8 @@ export async function medicationRoutes(app: FastifyInstance) {
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
isObsolete: row.isObsolete ?? false,
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: row.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
@@ -164,6 +188,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
pillWeightMg,
doseUnit,
medicationStartDate,
expiryDate,
notes,
prescriptionEnabled,
@@ -222,6 +247,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
medicationStartDate: medicationStartDate ?? "",
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
@@ -252,12 +278,15 @@ export async function medicationRoutes(app: FastifyInstance) {
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: inserted.pillWeightMg,
doseUnit: inserted.doseUnit ?? "mg",
medicationStartDate: inserted.medicationStartDate || null,
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
notes: inserted.notes,
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
isObsolete: inserted.isObsolete ?? false,
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
@@ -294,6 +323,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
pillWeightMg,
doseUnit,
medicationStartDate,
expiryDate,
notes,
prescriptionEnabled,
@@ -362,6 +392,7 @@ export async function medicationRoutes(app: FastifyInstance) {
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
medicationStartDate: medicationStartDate ?? "",
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
@@ -516,12 +547,15 @@ export async function medicationRoutes(app: FastifyInstance) {
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: result[0].pillWeightMg,
doseUnit: result[0].doseUnit ?? "mg",
medicationStartDate: result[0].medicationStartDate || null,
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
notes: result[0].notes,
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
isObsolete: result[0].isObsolete ?? false,
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
@@ -531,9 +565,67 @@ export async function medicationRoutes(app: FastifyInstance) {
};
});
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const [updated] = await db
.update(medications)
.set({
isObsolete: true,
obsoleteAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
return {
id: updated.id,
isObsolete: updated.isObsolete ?? false,
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
updatedAt: updated.updatedAt,
};
});
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
const [existing] = await db
.select()
.from(medications)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const [updated] = await db
.update(medications)
.set({
isObsolete: false,
obsoleteAt: null,
updatedAt: new Date(),
})
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
return {
id: updated.id,
isObsolete: updated.isObsolete ?? false,
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
updatedAt: updated.updatedAt,
};
});
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
// 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",
async (req, reply) => {
const idNum = Number(req.params.id);
@@ -548,16 +640,32 @@ export async function medicationRoutes(app: FastifyInstance) {
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
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 (
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
.update(medications)
.set({
stockAdjustment,
lastStockCorrectionAt: new Date(), // Mark when correction was made
updatedAt: new Date(),
})
.set(updateFields)
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
@@ -678,7 +786,17 @@ export async function medicationRoutes(app: FastifyInstance) {
}
const userId = await getUserId(req, reply);
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const rows = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.orderBy(medications.id);
const [settingsRow] = await db
.select({ stockCalculationMode: userSettings.stockCalculationMode })
.from(userSettings)
.where(eq(userSettings.userId, userId));
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
// Get all taken doses for this user to calculate actual consumption
const takenDoses = await db
@@ -686,20 +804,25 @@ export async function medicationRoutes(app: FastifyInstance) {
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
// Create a map of medication ID to taken dose count
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
const takenDoseIdsByMed = new Map<number, Set<string>>();
const takenDoseTimestamps = new Map<string, number>();
takenDoses.forEach((dose) => {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
if (!takenDosesMap.has(medId)) {
takenDosesMap.set(medId, []);
}
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
}
if (parts.length < 3) return;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) return;
if (!takenDoseIdsByMed.has(medId)) {
takenDoseIdsByMed.set(medId, new Set());
}
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
const rawTakenAt = Number(dose.takenAt);
const takenAtMs = Number.isFinite(rawTakenAt)
? rawTakenAt < 1_000_000_000_000
? rawTakenAt * 1000
: rawTakenAt
: new Date(dose.takenAt).getTime();
takenDoseTimestamps.set(dose.doseId, takenAtMs);
});
// Use current time as the reference point for "available" stock
@@ -726,69 +849,106 @@ export async function medicationRoutes(app: FastifyInstance) {
? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption based on ACTUAL taken doses from dose_tracking
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
// Use the same logic as frontend: generate expected doses and check which are marked
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
// Build a Set of taken dose IDs for quick lookup
const takenDoseIds = new Set(
takenDoses
.filter((dose) => {
const parts = dose.doseId.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
})
.map((dose) => dose.doseId)
);
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
// Count consumed pills by generating expected doses and checking if they're taken
let consumedUntilNow = 0;
const msPerDay = 86400000;
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
if (stockCalculationMode === "automatic") {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
const period = Math.max(1, blister.every) * msPerDay;
// After a stock correction, start counting from the NEXT scheduled
// dose, because the user's pill count already reflects all
// consumption up to the correction time.
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
effectiveStart = stockCorrectionCutoff + period;
} else {
effectiveStart = blisterStart.getTime();
}
if (effectiveStart > now.getTime()) return;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const fallbackPeople = parseTakenByJson(row.takenByJson);
const peopleForThisIntake = intakePerson
? [intakePerson]
: fallbackPeople.length > 0
? fallbackPeople
: [null];
// Get the people for this intake (from intakes array or medication takenBy)
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const peopleForThisIntake: (string | null)[] = intakePerson
? [intakePerson]
: takenByJson.length > 0
? takenByJson
: [null];
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
// Generate expected dose IDs and check if they're taken
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
if (effectiveStart <= now.getTime()) {
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoseIds.has(doseId)) {
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += blister.usage;
}
}
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
});
} else {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start);
const blisterStartDateOnly = new Date(
blisterStart.getFullYear(),
blisterStart.getMonth(),
blisterStart.getDate()
).getTime();
if (Number.isNaN(blisterStartDateOnly)) return;
for (const doseId of takenDoseIds) {
const parts = doseId.split("-");
if (parts.length < 3) continue;
const parsedBlisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
continue;
}
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
consumedUntilNow += blister.usage;
}
}
}
});
});
}
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
@@ -834,6 +994,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationId: row.id,
medicationName: row.name,
totalPills: currentStock,
currentPills: currentStock,
plannerUsage: usageTotal,
blisterSize: pillsPerBlister,
blistersNeeded,
+6 -3
View File
@@ -104,7 +104,7 @@ export async function oidcRoutes(app: FastifyInstance) {
});
return reply.redirect(authUrl.href);
} catch (err: any) {
} catch (err: unknown) {
console.error("[OIDC] Login error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
}
@@ -167,7 +167,10 @@ export async function oidcRoutes(app: FastifyInstance) {
// Extract username from configured claim
const usernameClaim = env.OIDC_USERNAME_CLAIM;
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;
if (!username || !oidcSubject) {
@@ -210,7 +213,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// In dev: CORS_ORIGINS contains the frontend URL
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: any) {
} catch (err: unknown) {
console.error("[OIDC] Callback error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
}
+67 -33
View File
@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
@@ -103,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
// Load user settings for notification channels
const userId = await getUserId(request);
const activeMeds = await db
.select({ id: medications.id })
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedIds = new Set(activeMeds.map((med) => med.id));
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
if (activeRows.length === 0) {
return reply.status(400).send({ error: "No active medications to notify" });
}
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
emailEnabled: userSettings.emailEnabled,
@@ -132,11 +142,11 @@ export async function plannerRoutes(app: FastifyInstance) {
})
);
const outOfStockCount = rows.filter((r) => !r.enough).length;
const outOfStockCount = activeRows.filter((r) => !r.enough).length;
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
// Load prescription data for medications referenced in planner rows
const medIds = rows.map((r) => r.medicationId).filter(Boolean);
const medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
const allMeds =
medIds.length > 0
? await db
@@ -156,7 +166,7 @@ ${t(dc.description, { from: fromDate, until: untilDate })}
${summaryText}
${rows
${activeRows
.map((r) => {
const isBottle = r.packageType === "bottle";
const usage = `${r.plannerUsage} ${tr.common.pills}`;
@@ -191,7 +201,7 @@ ${getFooterPlain(language)}`;
if (smtpHost && smtpUser) {
// Build HTML table with horizontal scroll for mobile
// Escape/coerce all user-provided values to prevent XSS
const tableRows = rows
const tableRows = activeRows
.map((row) => {
const safeName = escapeHtml(row.medicationName);
const safePlannerUsage = Number(row.plannerUsage) || 0;
@@ -312,7 +322,7 @@ ${getFooterPlain(language)}`;
// Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
const pushMessage = `${summaryText}\n\n${rows
const pushMessage = `${summaryText}\n\n${activeRows
.map((r) => {
const usage = `${r.plannerUsage} ${tr.common.pills}`;
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
@@ -360,6 +370,16 @@ ${getFooterPlain(language)}`;
// Load user settings
const userId = await getUserId(request);
const activeMeds = await db
.select({ name: medications.name })
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedNames = new Set(activeMeds.map((med) => med.name));
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
if (filteredLowStock.length === 0) {
return reply.status(400).send({ error: "No active medications to notify" });
}
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
emailEnabled: userSettings.emailEnabled,
@@ -374,9 +394,9 @@ ${getFooterPlain(language)}`;
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
// Separate into 3 categories: empty, critical, and low stock
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
// Build shared notification content (method-agnostic)
const titleParts: string[] = [];
@@ -489,8 +509,10 @@ ${getFooterPlain(language)}`;
const buildTableRow = (row: LowStockItem) => {
const isEmpty = row.medsLeft <= 0;
const isCritical = row.isCritical !== false;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
const safeDaysLeft = Number(row.daysLeft) || 0;
@@ -504,7 +526,7 @@ ${getFooterPlain(language)}`;
</tr>`;
};
const tableRows = lowStock.map(buildTableRow).join("");
const tableRows = filteredLowStock.map(buildTableRow).join("");
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
@@ -566,7 +588,7 @@ ${getFooterPlain(language)}`;
// Send push notification if enabled
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 {
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
@@ -583,12 +605,12 @@ ${getFooterPlain(language)}`;
// Update the reminder state to record this notification was sent
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);
// Also update user settings in database so frontend can display the info
const firstMed = lowStock[0];
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
await updateUserReminderSentTime(userId, "stock", channel, medNames);
}
@@ -618,14 +640,24 @@ ${getFooterPlain(language)}`;
}
const userId = await getUserId(request);
const activeMeds = await db
.select({ name: medications.name })
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedNames = new Set(activeMeds.map((med) => med.name));
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
if (filteredPrescriptionLow.length === 0) {
return reply.status(400).send({ error: "No active medications to notify" });
}
const userSettings = await loadUserSettings(userId);
const language = (userSettings.language as Language) || "en";
const tr = getTranslations(language);
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
const lines = prescriptionLow.map((item) => {
const lines = filteredPrescriptionLow.map((item) => {
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
if (item.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
@@ -640,7 +672,7 @@ ${getFooterPlain(language)}`;
})}`;
});
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
@@ -665,22 +697,23 @@ ${getFooterPlain(language)}`;
});
const subject =
prescriptionLow.length === 1
filteredPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText =
emptyRx.length > 0
? emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
: lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = prescriptionLow
const tableRows = filteredPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
@@ -778,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 {
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
@@ -794,7 +827,8 @@ ${getFooterPlain(language)}`;
}
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);
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
}
+23 -13
View File
@@ -52,23 +52,34 @@ export async function refillRoutes(app: FastifyInstance) {
if (!med) return reply.notFound("Medication not found");
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 (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
const remaining = med.prescriptionRemainingRefills ?? 0;
if (remaining <= 0) {
if (remainingPrescriptionRefills <= 0) {
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
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
const newRemainingRefills = usePrescription
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
await db
@@ -77,8 +88,6 @@ export async function refillRoutes(app: FastifyInstance) {
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
stockAdjustment: 0, // Reset offset since we're adding to base stock
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
@@ -89,16 +98,17 @@ export async function refillRoutes(app: FastifyInstance) {
.values({
medicationId: medId,
userId,
packsAdded,
loosePillsAdded,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
// Calculate pills added for response (packageType-aware)
const isBottle = (med.packageType ?? "blister") === "bottle";
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
? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
@@ -107,8 +117,8 @@ export async function refillRoutes(app: FastifyInstance) {
success: true,
refill: {
id: refill.id,
packsAdded,
loosePillsAdded,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
totalPillsAdded,
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 type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
@@ -33,6 +33,9 @@ export type UserSettings = {
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
lastAutoEmailSent: string | null;
lastNotificationType: string | null;
lastNotificationChannel: string | null;
@@ -69,6 +72,9 @@ type SettingsBody = {
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
};
type TestEmailBody = {
@@ -119,6 +125,9 @@ function getDefaultSettings() {
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
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,
lastNotificationType: null,
lastNotificationChannel: null,
@@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
@@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
@@ -239,7 +254,7 @@ export async function settingsRoutes(app: FastifyInstance) {
// Helper to get user ID from request
// 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 (!env.AUTH_ENABLED) {
return getAnonymousUserId();
@@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) {
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
// SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
@@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) {
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
updatedAt: new Date(),
};
@@ -544,7 +565,7 @@ export async function sendShoutrrrNotification(
}
// 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;
const method = "POST";
+2
View File
@@ -154,6 +154,8 @@ export async function shareRoutes(app: FastifyInstance) {
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
});
+121 -11
View File
@@ -22,7 +22,6 @@ import {
getTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
type IntakeReminderState,
parseIntakeReminderState,
parseIntakesJson,
@@ -51,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
if (intake.takenBy) {
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
}
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
locale: string,
tz: string,
logger: ServiceLogger
): Promise<number> {
if (settings.stockCalculationMode !== "automatic") {
return 0;
}
const now = new Date();
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
const existingToday = await db
.select({ doseId: doseTracking.doseId })
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, settings.userId),
gte(doseTracking.takenAt, todayStart),
lte(doseTracking.takenAt, todayEnd)
)
);
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
let inserted = 0;
for (const med of rows) {
if (med.isObsolete) {
continue;
}
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
if (intakes.length === 0) {
continue;
}
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const todaysIntakes = getTodaysIntakes(
med.name,
intakes,
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
for (const intake of todaysIntakes) {
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
continue;
}
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
continue;
}
const doseId = buildDoseIdForIntake({
...intake,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
});
if (existingDoseIds.has(doseId)) {
continue;
}
await db.insert(doseTracking).values({
userId: settings.userId,
doseId,
takenAt: intake.intakeTime,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
existingDoseIds.add(doseId);
inserted++;
}
}
if (inserted > 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
}
return inserted;
}
async function sendIntakeReminderEmail(
email: string,
intakes: UpcomingIntake[],
@@ -247,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const locale = getDateLocale(language);
const tz = getTimezone();
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
@@ -263,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
);
// Get all medications with intake reminders enabled for this user
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
@@ -281,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
const locale = getDateLocale(language);
const tz = getTimezone();
// Get start and end of today in user's timezone (for filtering today's doses only)
const now = new Date();
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
@@ -321,7 +430,7 @@ async function checkAndSendIntakeRemindersForUser(
});
// 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
logger.debug(
`[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);
// 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);
// Also update user settings in database so frontend can display the info
+192 -32
View File
@@ -1,10 +1,10 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { medications, userSettings } from "../db/schema.js";
import { doseTracking, medications, userSettings } from "../db/schema.js";
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
@@ -19,8 +19,10 @@ import {
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
parseBlisters,
parseIntakesJson,
parseLocalDateTime,
parseReminderState,
parseTakenByJson,
type ReminderState,
} from "../utils/scheduler-utils.js";
@@ -119,10 +121,6 @@ export async function updateUserReminderSentTime(
}
}
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
return parseBlisters(row);
}
type LowStockItem = {
name: string;
medsLeft: number;
@@ -142,19 +140,153 @@ async function getMedicationsNeedingReminder(
userId: number,
reminderDaysBefore: number,
lowStockDays: number,
language: Language
language: Language,
stockCalculationMode: "automatic" | "manual"
): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const rows = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.orderBy(medications.id);
const takenDoseRows = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
const takenDoseIdsByMed = new Map<number, Set<string>>();
const takenDoseTimestamps = new Map<string, number>();
for (const dose of takenDoseRows) {
const parts = dose.doseId.split("-");
if (parts.length < 3) continue;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) continue;
if (!takenDoseIdsByMed.has(medId)) {
takenDoseIdsByMed.set(medId, new Set());
}
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
const rawTakenAt = Number(dose.takenAt);
const takenAtMs = Number.isFinite(rawTakenAt)
? rawTakenAt < 1_000_000_000_000
? rawTakenAt * 1000
: rawTakenAt
: new Date(dose.takenAt).getTime();
takenDoseTimestamps.set(dose.doseId, takenAtMs);
}
const lowStock: LowStockItem[] = [];
const now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) {
const blisters = parseBlistersFromRow(row);
const totalPills =
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const originalTotalPills =
(row.packageType ?? "blister") === "bottle"
? row.looseTablets + (row.stockAdjustment ?? 0)
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
let consumed = 0;
if (stockCalculationMode === "automatic") {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
const period = Math.max(1, blister.every) * msPerDay;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const fallbackPeople = parseTakenByJson(row.takenByJson);
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [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;
@@ -164,7 +296,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) {
lowStock.push({
name: row.name,
medsLeft: totalPills,
medsLeft: currentPills,
daysLeft,
depletionDate,
isCritical,
@@ -176,7 +308,11 @@ async function getMedicationsNeedingReminder(
}
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const rows = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.orderBy(medications.id);
return rows
.filter(
@@ -192,6 +328,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
}));
}
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
export async function getMedicationsNeedingReminderForTests(
userId: number,
reminderDaysBefore: number,
lowStockDays: number,
language: Language,
stockCalculationMode: "automatic" | "manual"
): Promise<
Array<{
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical: boolean;
}>
> {
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
}
async function sendReminderEmail(
email: string,
lowStock: LowStockItem[],
@@ -267,8 +422,10 @@ async function sendReminderEmail(
.map((row) => {
const isEmpty = row.medsLeft <= 0;
const isCritical = row.isCritical;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
@@ -321,7 +478,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
---
${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 });
try {
@@ -392,7 +550,8 @@ async function checkAndSendReminderForUser(
settings.userId,
settings.reminderDaysBefore,
settings.lowStockDays,
language
language,
settings.stockCalculationMode
);
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
@@ -452,7 +611,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);
shoutrrrSuccess = result.success;
if (!result.success) {
@@ -462,7 +621,8 @@ async function checkAndSendReminderForUser(
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
@@ -472,7 +632,6 @@ async function checkAndSendReminderForUser(
lastNotificationChannel: channel,
});
const firstMed = allLowStock[0];
const medNames = allLowStock.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
}
@@ -529,14 +688,15 @@ async function checkAndSendReminderForUser(
const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
const alertText =
emptyRx.length > 0
? emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
: lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = allPrescriptionLow
.map((item) => {
@@ -641,7 +801,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);
shoutrrrSuccess = result.success;
if (!result.success) {
@@ -651,7 +811,8 @@ async function checkAndSendReminderForUser(
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
@@ -661,7 +822,6 @@ async function checkAndSendReminderForUser(
lastNotificationChannel: channel,
});
const firstMed = allPrescriptionLow[0];
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
}
+9 -9
View File
@@ -294,8 +294,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
// Should set cookies
const cookies = response.cookies;
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
});
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({
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({
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({
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({
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({
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({
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
const response = await app.inject({
+1 -1
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
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 {
+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);
const data = response.json();
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
for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number");
+202 -9
View File
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
const { settingsRoutes } = await import("../routes/settings.js");
const { healthRoutes } = await import("../routes/health.js");
const { refillRoutes } = await import("../routes/refills.js");
const { reportRoutes } = await import("../routes/report.js");
const { exportRoutes } = await import("../routes/export.js");
// =============================================================================
@@ -99,6 +100,9 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
prescription_authorized_refills integer,
prescription_remaining_refills integer,
@@ -134,6 +138,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
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_notification_type text,
last_notification_channel text,
@@ -164,6 +171,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -258,11 +266,80 @@ describe("E2E Tests with Real Routes", () => {
await app.register(settingsRoutes);
await app.register(healthRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
await app.register(exportRoutes);
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 () => {
await app.close();
testClient.close();
@@ -741,6 +818,39 @@ describe("E2E Tests with Real Routes", () => {
const data = getResponse.json();
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");
});
});
// ---------------------------------------------------------------------------
@@ -1668,7 +1778,7 @@ describe("E2E Tests with Real Routes", () => {
url: "/medications",
});
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);
const historyResponse = await app.inject({
@@ -1806,8 +1916,10 @@ describe("E2E Tests with Real Routes", () => {
const refills = response.json();
expect(refills).toHaveLength(2);
// Check both refills exist (order may vary)
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
const hasLooseRefill = refills.some(
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
);
expect(hasPackRefill).toBe(true);
expect(hasLooseRefill).toBe(true);
});
@@ -1885,7 +1997,7 @@ describe("E2E Tests with Real Routes", () => {
expect(getResponse.statusCode).toBe(200);
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.stockAdjustment).toBe(-7);
expect(med.lastStockCorrectionAt).toBeTruthy();
@@ -1931,7 +2043,7 @@ describe("E2E Tests with Real Routes", () => {
method: "GET",
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.stockAdjustment).toBe(-5);
});
@@ -2000,7 +2112,7 @@ describe("E2E Tests with Real Routes", () => {
// Verify adjustment is set
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);
// Edit medication with CHANGED stock fields (packCount 1 → 2)
@@ -2019,7 +2131,7 @@ describe("E2E Tests with Real Routes", () => {
// stockAdjustment should be reset to 0
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.lastStockCorrectionAt).toBeTruthy();
});
@@ -2063,7 +2175,7 @@ describe("E2E Tests with Real Routes", () => {
// stockAdjustment should be preserved
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.stockAdjustment).toBe(-5);
});
@@ -2111,7 +2223,7 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
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();
// 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)
@@ -2198,6 +2310,87 @@ describe("E2E Tests with Real Routes", () => {
expect(data.settings).toBeDefined();
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", () => {
+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
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
const EnvSchema = z.object({
+18 -9
View File
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
const userId = 1; // Test user ID
// Helper to parse blisters from DB
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const usage = JSON.parse(row.usage_json || "[]") as number[];
const every = JSON.parse(row.every_json || "[]") as number[];
const start = JSON.parse(row.start_json || "[]") as string[];
function parseBlisters(
row: Record<string, unknown>
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
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);
return Array.from({ length: len }, (_, i) => ({
usage: usage[i],
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
args: [userId],
});
let settings;
let settings: Record<string, unknown> | undefined;
if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0];
settings = {
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
});
// POST /import
app.post<{ Body: any }>("/import", async (request, reply) => {
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
app.post("/import", async (request, reply) => {
const importData = request.body as any;
// Basic validation
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
// Import medications
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications || []) {
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
const usageJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
);
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 result = await client.execute({
+11 -4
View File
@@ -94,6 +94,9 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
prescription_authorized_refills integer,
prescription_remaining_refills integer,
@@ -129,6 +132,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
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_notification_type text,
last_notification_channel text,
@@ -159,6 +165,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -1330,8 +1337,8 @@ describe("Integration Tests", () => {
url: "/medications",
});
const meds = medsRes.json();
const med1 = meds.find((m: any) => m.id === med1Id);
const med2 = meds.find((m: any) => m.id === med2Id);
const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
expect(med1.dismissedUntil).toBe("2025-01-15");
expect(med2.dismissedUntil).toBe("2025-01-15");
@@ -1373,7 +1380,7 @@ describe("Integration Tests", () => {
method: "GET",
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();
});
@@ -1443,7 +1450,7 @@ describe("Integration Tests", () => {
method: "GET",
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();
});
});
+151
View File
@@ -0,0 +1,151 @@
import cookie from "@fastify/cookie";
import Fastify, { type FastifyInstance } 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();
}
});
});
+20 -1
View File
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
// Mock sendShoutrrrNotification from settings
vi.mock("../routes/settings.js", async (importOriginal) => {
const original = (await importOriginal()) as any;
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
sendShoutrrrNotification: mockSendShoutrrr,
@@ -111,6 +111,9 @@ async function createSchema(client: Client) {
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
prescription_authorized_refills integer,
prescription_remaining_refills integer,
@@ -146,6 +149,9 @@ async function createSchema(client: Client) {
language text NOT NULL DEFAULT 'en',
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_notification_type text,
last_notification_channel text,
@@ -168,6 +174,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM medications");
await client.execute("DELETE FROM user_settings");
await client.execute("DELETE FROM users");
await client.execute("DELETE FROM sqlite_sequence");
@@ -188,6 +195,18 @@ describe("Planner Routes", () => {
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
);
// Insert test medications so active-medication filters pass
await testClient.execute({
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
args: [],
});
await testClient.execute({
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
args: [],
});
app = Fastify({ logger: false });
await app.register(plannerRoutes);
await app.ready();
+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 cors from "@fastify/cors";
import sensible from "@fastify/sensible";
import Fastify from "fastify";
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, describe, expect, it } from "vitest";
// Import from utils to avoid index.ts import side effects (server start)
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
refreshCookieOptions,
});
expect((app as any).config.accessTtl).toBe(15);
expect((app as any).config.refreshTtl).toBe(7);
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
const appWithConfig = app as unknown as {
config: {
accessTtl: number;
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();
});
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
const app = Fastify({ logger: false });
// Mock route plugins
const healthRoutes = async (app: any) => {
const healthRoutes = async (app: FastifyInstance) => {
app.get("/health", async () => ({ status: "ok" }));
};
const authRoutes = async (app: any) => {
const authRoutes = async (app: FastifyInstance) => {
app.post("/auth/login", async () => ({ token: "mock" }));
};
const medicationRoutes = async (app: any) => {
const medicationRoutes = async (app: FastifyInstance) => {
app.get("/medications", async () => []);
};
+2 -2
View File
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
const data = response.json();
expect(data).toHaveLength(2);
const medA = data.find((d: any) => d.medicationName === "Med A");
const medB = data.find((d: any) => d.medicationName === "Med B");
const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
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 {
const parsed = JSON.parse(intakesJson);
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,
every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
@@ -312,7 +312,7 @@ export type UpcomingIntake = {
export function getTodaysIntakes(
medName: string,
intakes: Intake[],
medicationTakenBy: string[], // Medication-level takenBy as fallback
_medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null,
locale: string,
tz?: string,
@@ -388,7 +388,7 @@ export function getUpcomingIntakes(
medName: string,
intakes: Intake[],
minutesBefore: number,
medicationTakenBy: string[], // Medication-level takenBy as fallback
_medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null,
locale: string,
tz?: string,
+10 -2
View File
@@ -2,14 +2,22 @@
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"files": {
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css", "frontend/e2e/**/*.ts", "frontend/playwright.config.ts"]
"includes": [
"backend/src/**/*.ts",
"frontend/src/**/*.ts",
"frontend/src/**/*.tsx",
"frontend/src/**/*.css",
"frontend/e2e/**/*.ts",
"frontend/playwright.config.ts"
]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
"noForEach": "off",
"noImportantStyles": "off"
},
"suspicious": {
"noExplicitAny": "warn",
+2
View File
@@ -30,6 +30,8 @@ services:
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
env_file:
- .env
environment:
- BACKEND_URL=http://backend-dev:3000
ports:
+12 -10
View File
@@ -2,7 +2,6 @@ import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect,
navigateTo,
type TestMedication,
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
await expect(ibuprofenRow).toBeVisible();
const rowText = await ibuprofenRow.textContent();
// 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 }) => {
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
await expect(todayBlock).toBeVisible({ timeout: 10000 });
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 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");
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
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 page.waitForLoadState("networkidle");
// Wait for undo button to appear (confirms the take succeeded)
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
try {
await expect(undoBtn).toBeVisible({ timeout: 10000 });
} catch {
// Take might have been rate-limited — skip this test gracefully
return;
}
await expect(undoBtn).toBeVisible({ timeout: 10000 });
await undoBtn.click();
await page.waitForLoadState("networkidle");
+102 -81
View File
@@ -38,58 +38,58 @@ async function fillAndSaveMedication(
intakes?: { usage: string; every: string }[];
}
): 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) {
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") {
await page.locator("select.package-type-select").selectOption("bottle");
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
await packageTypeSelect.selectOption("bottle");
await page.getByRole("tab", { name: /Package/i }).click();
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 {
await page.locator("select.package-type-select").selectOption("blister");
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
}
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
// Fill intake schedules
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
for (let i = 0; i < intakes.length; i++) {
if (i > 0) {
await page.getByRole("button", { name: /Intake/i }).click();
}
const row = page.locator(".blister-row").nth(i);
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
await row.getByLabel(/Every \(days\)/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.locator("form.form-grid 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);
await packageTypeSelect.selectOption("blister");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
if (opts.blistersPerPack)
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 form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
// Fill intake schedules
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
await page.getByRole("tab", { name: /Schedule/i }).click();
for (let i = 0; i < intakes.length; i++) {
if (i > 0) {
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
}
const row = form.locator(".blister-row").nth(i);
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
}
await page.waitForLoadState("networkidle");
await form.locator("button[type='submit']").click();
// 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 });
try {
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
* Helper: save after editing (PUT) and wait for success.
*/
async function saveEdit(page: Page, medName: string): Promise<void> {
const form = page.locator("form.form-grid:visible").first();
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
const medRow = page.locator(".med-row").filter({ hasText: medName });
try {
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
test("should not save with empty commercial name", async ({ page }) => {
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']");
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 }) => {
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
pillsPerBlister: "10",
});
// Form should reset — title should say "New medication"
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
// Commercial name should be empty
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
// Opening a fresh form after save should start with an empty commercial name.
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.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 medRow.locator("button.info").click();
// Form title should say "Edit medication"
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
// Form title should say "Edit entry" (or legacy "Edit medication").
await expect(
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
).toBeVisible();
// 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
await page.getByLabel(/Commercial Name/i).fill("After Edit");
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
// Save the edit
await saveEdit(page, "After Edit");
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
await medRow.locator("button.info").click();
// 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
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
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", () => {
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
// Accept the native confirm() dialog
page.on("dialog", (dialog) => dialog.accept());
await medRow.locator("button.danger").click();
await page
.locator(".confirm-modal-overlay, .modal-overlay")
.getByRole("button", { name: /Delete/i })
.click();
// 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
createdMeds.length = 0;
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
test.describe("Intake schedule management", () => {
test("should add and remove intake schedule rows", async ({ page }) => {
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();
expect(await page.locator(".blister-row").count()).toBe(2);
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await form.locator(".blister-row").count()).toBe(2);
await page.getByRole("button", { name: /Intake/i }).click();
expect(await page.locator(".blister-row").count()).toBe(3);
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await form.locator(".blister-row").count()).toBe(3);
const removeBtn = page
.locator(".blister-row")
.locator("form.form-grid:visible .blister-row")
.last()
.getByRole("button", { name: /Remove/i });
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 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 */
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
await page.waitForLoadState("networkidle");
// Click save
const saveBtn = page.locator("form.form-grid button[type='submit']");
await saveBtn.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 save request + re-fetch to complete
await page.waitForLoadState("networkidle");
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
await clickEditMed(page, "Edit GenName Med");
// 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("");
// Add a generic name
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
// Click edit again and verify the generic name was saved
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 }) => {
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
await clickEditMed(page, "Edit Notes Med");
await page.getByRole("tab", { name: /Package/i }).click();
// Notes should be empty initially
const notesField = page.getByLabel(/Notes/i);
const notesField = page.getByLabel(/(Notes|form\.notes)/i);
await expect(notesField).toHaveValue("");
// Add notes text
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
// Verify notes were saved by clicking edit again
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 }) => {
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
await clickEditMed(page, "Expiry Date Med");
await page.getByRole("tab", { name: /Package/i }).click();
// Set expiry date to 6 months from now
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 expect(expiryField).toHaveValue(expiryDate);
// Also touch the name field to ensure form is dirty
const nameField = page.getByLabel(/Commercial Name/i);
const currentName = await nameField.inputValue();
await nameField.fill(currentName);
// Expiry change itself is enough to persist in the current edit flow.
await saveEditAndVerify(page, "Expiry Date Med");
// Verify expiry date was saved
await clickEditMed(page, "Expiry Date Med");
await expect(page.getByLabel(/Expiry Date/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");
await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
});
test("should edit intake schedule usage and interval", async ({ page }) => {
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
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
const intakeRow = page.locator(".blister-row").first();
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill("2");
await everyField.fill("7");
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
// Verify the changes persisted
await clickEditMed(page, "Edit Intake Med");
const savedRow = page.locator(".blister-row").first();
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
});
test("should add a second intake schedule row", async ({ page }) => {
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
await clickEditMed(page, "Add Intake Med");
await page.getByRole("tab", { name: /Schedule/i }).click();
// Should have 1 intake row initially
await expect(page.locator(".blister-row")).toHaveCount(1);
// 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);
// Fill the new intake row
const secondRow = page.locator(".blister-row").nth(1);
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
await saveEditAndVerify(page, "Add Intake Med");
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
await clickEditMed(page, "Reminder Toggle Med");
await page.getByRole("tab", { name: /Schedule/i }).click();
// Find the remind checkbox in the intake row
const intakeRow = page.locator(".blister-row").first();
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
await navigateTo(page, "/medications");
await clickEditMed(page, "PackType Change Med");
const form = page.locator("form.form-grid:visible").first();
// 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");
// Blister-specific fields should be visible
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
// Blister-specific fields are shown in the Package tab.
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
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
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");
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
await clickEditMed(page, "Multi Edit Med");
// 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
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
// 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
const takenByInput = page.locator(".tag-input-container input");
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
// Verify all changes persisted
await clickEditMed(page, "Fully Edited Med");
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
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.use({ storageState: authFile });
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
async function openMedicationForm(page: Page) {
await navigateTo(page, "/medications");
const newMedicationButton = page.getByRole("button", { name: /New medication/i });
if (await newMedicationButton.isVisible().catch(() => false)) {
await newMedicationButton.click();
const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
if (await nameField.isVisible().catch(() => false)) return;
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");
// Should show either medication entries or the new medication form
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i });
const formTitle = page.locator("h2").filter({ hasText: /New medication/i });
const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
const hasList = await listTitle.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 }) => {
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();
// 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
await expect(page.getByText(/Intake schedule/i)).toBeVisible();
// Tabbed form should expose navigation to Package/Schedule sections
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 }) => {
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 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 expect(genericField).toHaveValue("Acetylsalicylic acid");
});
test("should have stock inventory fields", async ({ page }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Package/i }).click();
// Stock fields should be visible
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
// Package tab should expose stock-related fields for at least one package mode.
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 blistersField = page.getByLabel(/Blisters per pack/i);
const pillsField = page.getByLabel(/Pills per blister/i);
const capacityField = page.getByLabel(/Total Capacity/i);
const hasPacks = await packsField.isVisible().catch(() => false);
const hasTotal = await totalField.isVisible().catch(() => false);
const hasBlister = await blistersField.isVisible().catch(() => false);
const hasBottle = await capacityField.isVisible().catch(() => false);
expect(hasBlister || hasBottle).toBeTruthy();
expect(hasPacks || hasTotal).toBeTruthy();
});
test("should toggle package type between blister and bottle", async ({ page }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Package/i }).click();
// Find the package type radio buttons or selector
const blisterOption = page.getByText(/Blister Pack/i);
const bottleOption = page.getByText(/Pill Bottle/i);
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
if (await blisterOption.isVisible().catch(() => false)) {
// Switch to bottle
await bottleOption.click();
// 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
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 }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
await page.getByRole("tab", { name: /Schedule/i }).click();
// Intake schedule section
const scheduleSection = page.getByText(/Intake schedule/i);
await expect(scheduleSection).toBeVisible();
await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
// 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
const addIntake = page.getByRole("button", { name: /Intake/i });
const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
await expect(addIntake).toBeVisible();
});
test("should have save and cancel buttons", async ({ page }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
// 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
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 }) => {
await openMedicationForm(page);
const form = visibleMedForm(page);
// 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
await page.locator('button.pill:has-text("Dashboard")').click();
-1
View File
@@ -3,7 +3,6 @@ import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect,
navigateTo,
type TestMedication,
+1 -2
View File
@@ -2,7 +2,6 @@ import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect,
navigateTo,
type TestMedication,
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
await expect(todayBlock).toBeVisible({ timeout: 15000 });
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 page.waitForLoadState("networkidle");
+56 -50
View File
@@ -1,5 +1,5 @@
import { expect } from "@playwright/test";
import { authFile, navigateTo, test } from "./fixtures";
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
/**
* Schedule / Timeline E2E Tests
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
test.describe("Schedule Timeline", () => {
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 }) => {
await navigateTo(page, "/dashboard");
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
test("should show past days toggle when medications exist", async ({ page }) => {
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 hasPastToggle = await pastToggle.isVisible().catch(() => false);
// Just verify it doesn't crash — visibility depends on medication data
expect(typeof hasPastToggle).toBe("boolean");
await expect(pastToggle).toBeVisible();
});
test("should expand/collapse past days on click", async ({ page }) => {
await navigateTo(page, "/dashboard");
const pastToggle = page.locator(".past-days-toggle");
if (!(await pastToggle.isVisible().catch(() => false))) {
// No medications — past days toggle not shown
return;
}
await expect(pastToggle).toBeVisible();
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 }) => {
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 hasFutureToggle = await futureToggle.isVisible().catch(() => false);
expect(typeof hasFutureToggle).toBe("boolean");
await expect(futureToggle).toBeVisible();
});
test("should display day blocks in timeline", async ({ page }) => {
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");
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 }) => {
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 hasTodayBlock = await todayBlock.isVisible().catch(() => false);
// Today block exists only if there are medications with schedules
if (hasTodayBlock) {
await expect(todayBlock).toBeVisible();
// Should have a day divider with date text
await expect(todayBlock.locator(".day-date")).toBeVisible();
}
await expect(todayBlock).toBeVisible();
await expect(todayBlock.locator(".day-date")).toBeVisible();
});
test("should show day summary with progress", async ({ page }) => {
await navigateTo(page, "/dashboard");
const todayBlock = page.locator(".day-block.today");
if (await todayBlock.isVisible().catch(() => false)) {
const summary = todayBlock.locator(".day-summary");
await expect(summary).toBeVisible();
}
await expect(todayBlock).toBeVisible();
const summary = todayBlock.locator(".day-summary");
await expect(summary).toBeVisible();
});
test("should collapse/expand a day block", async ({ page }) => {
await navigateTo(page, "/dashboard");
const todayBlock = page.locator(".day-block.today");
if (await todayBlock.isVisible().catch(() => false)) {
const dayDivider = todayBlock.locator(".day-divider");
await dayDivider.click();
await expect(todayBlock).toBeVisible();
const dayDivider = todayBlock.locator(".day-divider");
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();
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
await dayDivider.click();
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 }) => {
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
// Overview table has class .table.table-7
const overviewTable = page.locator(".table.table-7");
const hasTable = await overviewTable.isVisible().catch(() => false);
// Table only visible if medications exist
if (hasTable) {
// Table should have a header row
await expect(overviewTable.locator(".table-head")).toBeVisible();
}
await expect(overviewTable).toBeVisible();
await expect(overviewTable.locator(".table-head")).toBeVisible();
});
test("should display share button in schedules section", async ({ page }) => {
await navigateTo(page, "/dashboard");
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
const shareBtn = page.locator("button.share-btn");
// Share button only visible if there are takenBy users
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
// Just verify it's either visible or not (no crash)
expect(typeof hasShareBtn).toBe("boolean");
await expect(shareBtn).toBeVisible();
});
});
+1 -4
View File
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
}
}
if (!enabledToggle) {
// All toggles disabled (no notification channels configured) — skip
return;
}
test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
const checkbox = enabledToggle.locator('input[type="checkbox"]');
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)
// 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 medName = page.getByText(MED_ALICE);
+6 -1
View File
@@ -8,12 +8,17 @@
# 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)
export NGINX_ACCESS_LOG="off"
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
;;
*)
export NGINX_ACCESS_LOG="/dev/stdout"
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
;;
esac
+4
View File
@@ -43,5 +43,9 @@ server {
# Timeout for uploads
proxy_read_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;
}
}
+131 -110
View File
@@ -1,15 +1,16 @@
{
"name": "medassist-ng-frontend",
"version": "1.10.3",
"version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.10.3",
"version": "1.12.0",
"dependencies": {
"i18next": "^25.8.7",
"i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.574.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
@@ -17,7 +18,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@biomejs/biome": "^2.4.1",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -27,7 +28,7 @@
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"typescript": "^5.5.4",
"vite": "^7.3.1",
"vitest": "^4.0.17"
@@ -48,23 +49,23 @@
"license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.4",
"@csstools/css-color-parser": "^3.1.0",
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4",
"lru-cache": "^11.2.4"
"@csstools/css-calc": "^3.0.0",
"@csstools/css-color-parser": "^4.0.1",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.5"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -72,9 +73,9 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -82,13 +83,13 @@
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"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": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -404,9 +405,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz",
"integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -420,20 +421,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.15",
"@biomejs/cli-darwin-x64": "2.3.15",
"@biomejs/cli-linux-arm64": "2.3.15",
"@biomejs/cli-linux-arm64-musl": "2.3.15",
"@biomejs/cli-linux-x64": "2.3.15",
"@biomejs/cli-linux-x64-musl": "2.3.15",
"@biomejs/cli-win32-arm64": "2.3.15",
"@biomejs/cli-win32-x64": "2.3.15"
"@biomejs/cli-darwin-arm64": "2.4.1",
"@biomejs/cli-darwin-x64": "2.4.1",
"@biomejs/cli-linux-arm64": "2.4.1",
"@biomejs/cli-linux-arm64-musl": "2.4.1",
"@biomejs/cli-linux-x64": "2.4.1",
"@biomejs/cli-linux-x64-musl": "2.4.1",
"@biomejs/cli-win32-arm64": "2.4.1",
"@biomejs/cli-win32-x64": "2.4.1"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz",
"integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
"cpu": [
"arm64"
],
@@ -448,9 +449,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz",
"integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
"cpu": [
"x64"
],
@@ -465,9 +466,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz",
"integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
"cpu": [
"arm64"
],
@@ -482,9 +483,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz",
"integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
"cpu": [
"arm64"
],
@@ -499,9 +500,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz",
"integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
"cpu": [
"x64"
],
@@ -516,9 +517,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz",
"integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
"cpu": [
"x64"
],
@@ -533,9 +534,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz",
"integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
"cpu": [
"arm64"
],
@@ -550,9 +551,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz",
"integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
"cpu": [
"x64"
],
@@ -566,10 +567,23 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
"integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
"dev": true,
"funding": [
{
@@ -583,13 +597,13 @@
],
"license": "MIT-0",
"engines": {
"node": ">=18"
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
@@ -603,17 +617,17 @@
],
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
"integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
"dev": true,
"funding": [
{
@@ -627,21 +641,21 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
"@csstools/color-helpers": "^6.0.1",
"@csstools/css-calc": "^3.0.0"
},
"engines": {
"node": ">=18"
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
@@ -655,16 +669,16 @@
],
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.25",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
"integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
"version": "1.0.27",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
"integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
"dev": true,
"funding": [
{
@@ -676,15 +690,12 @@
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
"license": "MIT-0"
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
@@ -698,7 +709,7 @@
],
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -2139,25 +2150,25 @@
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
"integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^4.1.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
"@asamuzakjp/css-color": "^4.1.2",
"@csstools/css-syntax-patches-for-csstree": "^1.0.26",
"css-tree": "^3.1.0",
"lru-cache": "^11.2.4"
"lru-cache": "^11.2.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cssstyle/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -2438,9 +2449,9 @@
}
},
"node_modules/i18next": {
"version": "25.8.7",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz",
"integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==",
"version": "25.8.10",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
"integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
"funding": [
{
"type": "individual",
@@ -2540,16 +2551,17 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "28.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"cssstyle": "^5.3.7",
"cssstyle": "^6.0.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
@@ -2560,7 +2572,7 @@
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
"undici": "^7.20.0",
"undici": "^7.21.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
@@ -2627,6 +2639,15 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+13 -8
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.11.1",
"version": "1.14.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,15 +14,20 @@
"test": "vitest",
"test:run": "vitest run",
"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:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
"test:e2e: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"
},
"dependencies": {
"i18next": "^25.8.7",
"i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.574.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
@@ -30,7 +35,7 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@biomejs/biome": "^2.4.1",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -40,7 +45,7 @@
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"jsdom": "^28.0.0",
"jsdom": "^28.1.0",
"typescript": "^5.5.4",
"vite": "^7.3.1",
"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";
/**
* 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,
},
],
});
export default buildPlaywrightConfig(false);
+3
View File
@@ -0,0 +1,3 @@
import { buildPlaywrightConfig } from "./playwright.base.config";
export default buildPlaywrightConfig(false);
+43 -13
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import {
AboutModal,
Lightbox,
@@ -112,6 +112,7 @@ function AppRouter() {
// =============================================================================
function AppContent() {
const navigate = useNavigate();
// Get shared state from AppContext
const ctx = useAppContext();
const {
@@ -139,7 +140,10 @@ function AppContent() {
setEditStockFullBlisters,
editStockPartialBlisterPills,
setEditStockPartialBlisterPills,
editStockLoosePills,
setEditStockLoosePills,
editStockSaving,
editStockMedication,
openRefillModal,
closeRefillModal,
openEditStockModal,
@@ -289,18 +293,25 @@ function AppContent() {
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
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");
});
};
const handleTooltipClick = (e: Event) => {
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
closeAllTooltips();
// 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 {
closeAllTooltips();
}
@@ -351,9 +362,11 @@ function AppContent() {
}
}, [meds, selectedMed, setSelectedMed]);
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
const handleSubmitStockCorrection = async (medId: number) => {
if (!selectedMed) return;
await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
if (!stockCorrectionMed) return;
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
};
// For MedDetailModal: refill without form update (not editing)
@@ -361,11 +374,19 @@ function AppContent() {
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
};
// Wrapper for openEditStockModal (provides selectedMed and coverage)
const handleOpenEditStockModal = () => {
if (selectedMed) {
openEditStockModal(selectedMed, coverage);
}
const handleOpenMedicationEdit = () => {
if (!selectedMed) return;
const medId = selectedMed.id;
setShowImageLightbox(false);
setShowRefillModal(false);
setShowEditStockModal(false);
setSelectedMed(null);
navigate(`/medications?editMedId=${medId}`);
};
const handleOpenEditStockFromDetail = () => {
if (!selectedMed) return;
openEditStockModal(selectedMed, coverage);
};
function openProfile() {
@@ -415,18 +436,20 @@ function AppContent() {
{/* Medication Detail Modal */}
<MedDetailModal
selectedMed={selectedMed}
selectedMed={stockCorrectionMed}
coverage={coverage}
settings={stockThresholds}
showImageLightbox={showImageLightbox}
showRefillModal={showRefillModal}
showEditStockModal={showEditStockModal}
editStockOnly={showEditStockModal && !selectedMed}
onClose={closeMedDetail}
onOpenImageLightbox={openImageLightbox}
onCloseImageLightbox={closeImageLightbox}
onOpenRefillModal={openRefillModal}
onCloseRefillModal={closeRefillModal}
onOpenEditStockModal={handleOpenEditStockModal}
onOpenMedicationEdit={handleOpenMedicationEdit}
onOpenEditStockModal={handleOpenEditStockFromDetail}
onCloseEditStockModal={closeEditStockModal}
refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks}
@@ -443,6 +466,8 @@ function AppContent() {
onEditStockFullBlistersChange={setEditStockFullBlisters}
editStockPartialBlisterPills={editStockPartialBlisterPills}
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
editStockLoosePills={editStockLoosePills}
onEditStockLoosePillsChange={setEditStockLoosePills}
editStockSaving={editStockSaving}
onSubmitStockCorrection={handleSubmitStockCorrection}
/>
@@ -454,6 +479,11 @@ function AppContent() {
coverage={coverage}
settings={stockThresholds}
onClose={closeUserFilter}
onClearUser={() => {
setSelectedUser(null);
// Replace the userFilter history entry so it doesn't remain on the stack
window.history.replaceState(null, "");
}}
onOpenMedDetail={openMedDetail}
/>
+12 -2
View File
@@ -51,8 +51,18 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
<div
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>
-1
View File
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { useUnsavedChanges } from "../context";
import type { ThemePreference } from "../hooks";
import { useTheme } from "../hooks";
import { useAuth } from "./Auth";
+1 -1
View File
@@ -756,7 +756,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="profile-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
{t("common.close", "Close")}
</button>
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
+26 -4
View File
@@ -2,7 +2,7 @@
// ConfirmModal Component - Simple confirmation dialog
// =============================================================================
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
export interface ConfirmModalProps {
title: string;
@@ -12,7 +12,7 @@ export interface ConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
confirmVariant?: "primary" | "danger" | "success";
confirmVariant?: "primary" | "danger" | "success" | "warning";
overlayClassName?: string;
}
@@ -27,9 +27,31 @@ export function ConfirmModal({
confirmVariant = "primary",
overlayClassName,
}: ConfirmModalProps) {
// Close on Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onCancel();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
return (
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
<div
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>
+44
View File
@@ -0,0 +1,44 @@
/**
* DateInput - Custom date input that displays dates in the regional locale format.
*
* Overlays a formatted date string on top of a native <input type="date">,
* so the browser calendar popup still works but the displayed text
* uses our locale-aware formatting (e.g., 14.02.2026 for Germany).
*/
import { type InputHTMLAttributes, useCallback, useRef } from "react";
import { formatDate, getNumericLocale } from "../utils/formatters";
interface DateInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export function DateInput({ value, placeholder, className, ...rest }: DateInputProps) {
const locale = getNumericLocale();
const displayValue = value ? formatDate(value, locale) : "";
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
try {
inputRef.current?.showPicker();
} catch {
// showPicker() may throw in some browsers — fallback to focus
inputRef.current?.focus();
}
}, []);
return (
<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">
{displayValue || placeholder || ""}
</span>
<input ref={inputRef} type="date" className="date-input-native" value={value} {...rest} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* DateTimeInput - Custom datetime input that displays date+time in the regional locale format.
*
* Overlays a formatted datetime string on top of a native <input type="datetime-local">,
* so the browser datetime popup still works but the displayed text
* uses our locale-aware formatting (e.g., 14.02.2026, 20:30 for Germany).
*/
import { type InputHTMLAttributes, useCallback, useRef } from "react";
import { formatDateTime, getNumericLocale } from "../utils/formatters";
interface DateTimeInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export function DateTimeInput({ value, placeholder, className, ...rest }: DateTimeInputProps) {
const locale = getNumericLocale();
// datetime-local value is "YYYY-MM-DDTHH:MM" — formatDateTime handles this format
const displayValue = value ? formatDateTime(value, locale) : "";
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
try {
inputRef.current?.showPicker();
} catch {
// showPicker() may throw in some browsers — fallback to focus
inputRef.current?.focus();
}
}, []);
return (
<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">
{displayValue || placeholder || ""}
</span>
<input ref={inputRef} type="datetime-local" className="date-input-native" value={value} {...rest} />
</div>
);
}
+14 -3
View File
@@ -13,8 +13,19 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
<div
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>
@@ -53,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
</div>
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
<button type="button" className="ghost" onClick={onClose}>
{t("exportImport.cancelButton")}
{t("common.close")}
</button>
</div>
</div>
+25 -4
View File
@@ -3,6 +3,7 @@
// =============================================================================
import type { MouseEvent } from "react";
import { useEffect } from "react";
export interface LightboxProps {
src: string;
@@ -11,7 +12,19 @@ export interface LightboxProps {
}
export function Lightbox({ src, alt, onClose }: LightboxProps) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function handleOverlayClick(e: MouseEvent) {
e.stopPropagation();
if (e.target === e.currentTarget) {
onClose();
}
@@ -19,10 +32,18 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
return (
<div className="lightbox-overlay" onClick={handleOverlayClick}>
<button className="lightbox-close" onClick={onClose}>
×
</button>
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
<div className="lightbox-container">
<button className="lightbox-close" onClick={onClose}>
×
</button>
<img
src={src}
alt={alt}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -9,8 +9,18 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
<div
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>
+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;
+103 -67
View File
@@ -2,6 +2,8 @@
* ShareDialog - Modal for generating share links for medication schedules
* Allows sharing schedule view for a specific person
*/
import { Check, Copy, Link2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
export interface ShareDialogProps {
@@ -38,86 +40,120 @@ export function ShareDialog({
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
if (!show) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>
×
<div
className="modal-overlay"
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>
<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>
</div>
{sharePeople.length === 0 ? (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
</div>
) : shareLink ? (
<div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p>
<div className="share-link-box">
<input
type="text"
value={shareLink}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button className="btn-copy" onClick={onCopyShareLink}>
{shareCopied ? "✓" : "📋"}
</button>
</div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
<div className="share-dialog-footer">
<button
className="ghost"
onClick={() => {
onShareLinkChange(null);
onShareCopiedChange(false);
}}
>
{t("share.generateAnother")}
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
) : (
<div className="share-dialog-form">
<div className="form-group">
<label>{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</div>
{(() => {
if (sharePeople.length === 0) {
return (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
</div>
);
}
if (shareLink) {
return (
<div className="share-dialog-result">
<p className="share-success">{t("share.linkGenerated")}</p>
<div className="share-link-box">
<input
type="text"
value={shareLink}
readOnly
className="share-link-input"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
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>
</div>
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
<div className="share-dialog-footer">
<button
className="ghost"
onClick={() => {
onShareLinkChange(null);
onShareCopiedChange(false);
}}
>
{t("share.generateAnother")}
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
</div>
);
}
return (
<div className="share-dialog-form">
<div className="form-group">
<label>{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</div>
<div className="form-group">
<label>{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
</div>
<div className="form-group">
<label>{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
</div>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.cancel")}
</button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.close")}
</button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
</div>
</div>
)}
);
})()}
</div>
</div>
);
+105 -36
View File
@@ -209,7 +209,7 @@ export function SharedSchedule() {
// 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
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
return doseId;
}
@@ -479,7 +479,8 @@ export function SharedSchedule() {
const intake = intakes[blisterIdx];
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 lastAutoConsumedDateMs = 0;
@@ -579,11 +580,13 @@ export function SharedSchedule() {
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
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
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)
function isDoseIdDone(doseId: string): boolean {
@@ -606,7 +609,7 @@ export function SharedSchedule() {
const missedPastDoseIds = useMemo(() => {
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
}, [pastDays, takenDoses, dismissedDoses, data]);
}, [pastDays, isDoseIdDone]);
if (loading) {
return (
@@ -714,14 +717,20 @@ export function SharedSchedule() {
</div>
</div>
</div>
<p className="shared-schedule-period">
{t("share.period")}:{" "}
{data.scheduleDays === 30
? t("dashboard.schedules.1month")
: data.scheduleDays === 90
? t("dashboard.schedules.3months")
: t("dashboard.schedules.6months")}
</p>
{!showOnlyToday &&
(() => {
const periodLabel =
data.scheduleDays === 30
? t("dashboard.schedules.1month")
: data.scheduleDays === 90
? t("dashboard.schedules.3months")
: t("dashboard.schedules.6months");
return (
<p className="shared-schedule-period">
{t("share.period")}: {periodLabel}
</p>
);
})()}
</header>
<div className="timeline">
@@ -730,7 +739,8 @@ export function SharedSchedule() {
) : (
<>
{/* Past days (when expanded) — rendered above toggle */}
{showPastDays &&
{!showOnlyToday &&
showPastDays &&
pastDays.map((day) => {
// 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));
@@ -757,14 +767,18 @@ export function SharedSchedule() {
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : "";
return (
<div
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
className="day-divider clickable"
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")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -817,11 +831,18 @@ export function SharedSchedule() {
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
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" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -837,9 +858,12 @@ export function SharedSchedule() {
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
<div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
@@ -859,7 +883,8 @@ export function SharedSchedule() {
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
@@ -875,7 +900,8 @@ export function SharedSchedule() {
);
})}
{/* Past days toggle */}
{pastDays.length > 0 &&
{!showOnlyToday &&
pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
@@ -894,6 +920,9 @@ export function SharedSchedule() {
}, 50);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
@@ -941,6 +970,9 @@ export function SharedSchedule() {
<div
className="day-divider clickable"
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")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -982,11 +1014,18 @@ export function SharedSchedule() {
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
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" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1006,9 +1045,12 @@ export function SharedSchedule() {
>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
<div className="dose-checks">
<div
@@ -1030,7 +1072,8 @@ export function SharedSchedule() {
title={t("dose.markAsTaken")}
disabled={isEmpty}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
@@ -1047,7 +1090,8 @@ export function SharedSchedule() {
})()}
{/* Future days toggle — identical to DashboardPage */}
{futureDays.length > 0 &&
{!showOnlyToday &&
futureDays.length > 0 &&
(() => {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
@@ -1058,6 +1102,9 @@ export function SharedSchedule() {
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
}}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
@@ -1079,7 +1126,8 @@ export function SharedSchedule() {
})()}
{/* Future days (when expanded) — identical to DashboardPage */}
{showFutureDays &&
{!showOnlyToday &&
showFutureDays &&
futureDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
@@ -1099,6 +1147,9 @@ export function SharedSchedule() {
<div
className="day-divider clickable"
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")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -1139,11 +1190,18 @@ export function SharedSchedule() {
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
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" />
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
<div className="med-name-stack">
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
</div>
</div>
<div className="tag-row">
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
@@ -1159,9 +1217,12 @@ export function SharedSchedule() {
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
<div className="dose-checks">
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
@@ -1181,7 +1242,8 @@ export function SharedSchedule() {
title={t("dose.markAsTaken")}
disabled={true}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
@@ -1215,7 +1277,13 @@ export function SharedSchedule() {
{/* Image Lightbox */}
{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>
@@ -1224,6 +1292,7 @@ export function SharedSchedule() {
alt={lightboxImage.name}
className="lightbox-image"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
+58 -6
View File
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
import type { Coverage, Medication, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -15,6 +16,7 @@ export interface UserFilterModalProps {
coverage: { all: Coverage[] };
settings: StockThresholds;
onClose: () => void;
onClearUser: () => void;
onOpenMedDetail: (med: Medication) => void;
}
@@ -24,17 +26,28 @@ export function UserFilterModal({
coverage,
settings,
onClose,
onClearUser,
onOpenMedDetail,
}: UserFilterModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
if (!selectedUser) return null;
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
<div
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>
@@ -47,22 +60,61 @@ export function UserFilterModal({
<div className="user-meds-list">
{userMeds.map((med) => {
const medCoverage = coverage.all.find((c) => c.name === med.name);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
const status = medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
: getStockStatus(null, getMedTotal(med), settings);
const packageSize = getPackageSize(med);
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
// Get intakes relevant to this person
const personIntakes = (
med.intakes ||
med.blisters.map((b) => ({
...b,
takenBy: null as string | null,
intakeRemindersEnabled: false,
}))
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
return (
<div
key={med.id}
className="user-med-item clickable"
onClick={() => {
onClose();
onClearUser();
onOpenMedDetail(med);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClearUser();
onOpenMedDetail(med);
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
<span className="user-med-name">{med.name}</span>
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
{personIntakes.length > 0 && (
<div className="user-med-intakes">
{personIntakes.map((intake, idx) => {
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit",
minute: "2-digit",
});
return (
<span key={idx} className="user-med-intake-item">
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
{med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
{t("modal.at")} {timeStr}
</span>
);
})}
</div>
)}
</div>
<div className="user-med-stats">
<span className="user-med-pills">
+3
View File
@@ -3,6 +3,8 @@
export { default as AboutModal } from "./AboutModal";
export type { ConfirmModalProps } from "./ConfirmModal";
export { ConfirmModal } from "./ConfirmModal";
export { DateInput } from "./DateInput";
export { DateTimeInput } from "./DateTimeInput";
export { default as ExportModal } from "./ExportModal";
export type { LightboxProps } from "./Lightbox";
@@ -15,6 +17,7 @@ export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal";
export { default as ReportModal } from "./ReportModal";
export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog";
export { SharedSchedule } from "./SharedSchedule";
+60 -21
View File
@@ -3,10 +3,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
// =============================================================================
// Types
@@ -72,6 +72,7 @@ export interface AppContextValue {
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
@@ -119,12 +120,15 @@ export interface AppContextValue {
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockLoosePills: number;
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean;
editStockMedication: Medication | null;
loadRefillHistory: (medId: number) => Promise<void>;
submitRefill: (
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<any>>,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void,
usePrescription?: boolean
) => Promise<void>;
@@ -175,8 +179,20 @@ export interface AppContextValue {
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importResult: { medications: number; doses: number; shares: number } | null;
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
importResult: {
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>;
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleImportConfirm: () => Promise<void>;
@@ -197,7 +213,17 @@ export interface AppContextValue {
// Context
// =============================================================================
const AppContext = createContext<AppContextValue | null>(null);
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
const AppContext = (() => {
const globalRef = globalThis as typeof globalThis & {
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
};
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
}
return globalRef[APP_CONTEXT_SINGLETON_KEY];
})();
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
@@ -237,7 +263,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
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
useEffect(() => {
@@ -270,15 +301,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Computed values - combine app language with timezone region for locale
const systemLocale = getSystemLocale(i18n.language);
const schedule = useMemo(
() => buildSchedulePreview(medications.meds, systemLocale, true),
[medications.meds, systemLocale]
);
const activeMeds = useMemo(() => medications.meds.filter((m) => !m.isObsolete), [medications.meds]);
const schedule = useMemo(() => buildSchedulePreview(activeMeds, systemLocale, true), [activeMeds, systemLocale]);
const coverage = useMemo(
() =>
calculateCoverage(
medications.meds,
activeMeds,
schedule.events,
systemLocale,
settingsHook.settings.reminderDaysBefore,
@@ -287,7 +316,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
doses.takenDoseTimestamps
),
[
medications.meds,
activeMeds,
schedule.events,
systemLocale,
settingsHook.settings.reminderDaysBefore,
@@ -351,7 +380,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Normal/High stock
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]
);
@@ -430,8 +460,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}, [groupedSchedule, scheduleDays]);
const missedPastDoseIds = useMemo(
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
);
// Modal helpers with browser history support
@@ -486,8 +516,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
// Wrapper to pass meds to openShareDialog
const openShareDialog = useCallback(() => {
share.openShareDialog(medications.meds);
}, [share, medications.meds]);
share.openShareDialog(activeMeds);
}, [share, activeMeds]);
// Get t function for translations
const { t } = useTranslation();
@@ -507,9 +537,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
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.download = `${t("exportImport.downloadFilename")}-${dateStr}.json`;
a.download = `${t("exportImport.downloadFilename")}${userPart}-${dateStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -519,7 +551,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}
setExporting(false);
},
[t]
[t, user?.username]
);
// Handle file selection for import
@@ -583,6 +615,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setImportResult({
medications: data.imported?.medications || 0,
doses: data.imported?.doseHistory || 0,
refills: data.imported?.refillHistory || 0,
shares: data.imported?.shareLinks || 0,
});
@@ -625,6 +658,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
);
}, [settingsHook.settings, settingsHook.savedSettings]);
@@ -708,6 +743,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken,
@@ -755,7 +791,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setEditStockFullBlisters: refill.setEditStockFullBlisters,
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
editStockLoosePills: refill.editStockLoosePills,
setEditStockLoosePills: refill.setEditStockLoosePills,
editStockSaving: refill.editStockSaving,
editStockMedication: refill.editStockMedication,
loadRefillHistory: refill.loadRefillHistory,
submitRefill: refill.submitRefill,
submitStockCorrection: refill.submitStockCorrection,
+1
View File
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
export type { UseMedicationsReturn } from "./useMedications";
export { useMedications } from "./useMedications";
export { useModalHistory } from "./useModalHistory";
export type { UseRefillReturn } from "./useRefill";
export { useRefill } from "./useRefill";
export type { Settings, UseSettingsReturn } from "./useSettings";
+30
View File
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
dismissedDoses: Set<string>;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
const data = await res.json();
const taken = new Set<string>();
const timestamps = new Map<string, number>();
const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
} else {
taken.add(d.doseId);
timestamps.set(d.doseId, d.takenAt);
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
}
}
setTakenDoses(taken);
setTakenDoseTimestamps(timestamps);
setTakenDoseSources(sources);
setDismissedDoses(dismissed);
}
// Don't reset on error - keep current state
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
return person ? `${baseDoseId}-${person}` : baseDoseId;
}, []);
const isDoseTakenAutomatically = useCallback(
(doseId: string): boolean => {
return takenDoseSources.get(doseId) === "automatic";
},
[takenDoseSources]
);
// Count taken doses for a day/item
const countTakenDoses = useCallback(
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
next.set(doseId, Date.now());
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.set(doseId, "manual");
return next;
});
// Send to server
try {
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
next.delete(doseId);
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
} finally {
mutationInFlightRef.current--;
// Re-sync with server after mutation completes
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
next.delete(doseId);
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
// Send to server
try {
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
takenDoses,
setTakenDoses,
takenDoseTimestamps,
takenDoseSources,
dismissedDoses,
showClearMissedConfirm,
setShowClearMissedConfirm,
getDoseId,
isDoseTakenAutomatically,
countTakenDoses,
markDoseTaken,
undoDoseTaken,
+6 -5
View File
@@ -41,6 +41,7 @@ export const defaultForm = (): FormState => ({
looseTablets: "0",
pillWeightMg: "",
doseUnit: "mg",
medicationStartDate: "",
expiryDate: "",
notes: "",
prescriptionEnabled: false,
@@ -189,6 +190,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit
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
const intakesFromApi =
@@ -214,6 +216,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), 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 = {
name: med.name,
genericName: med.genericName ?? "",
@@ -222,14 +225,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
totalPills: med.totalPills
? String(med.totalPills)
: med.packageType === "bottle" && med.looseTablets
? String(med.looseTablets)
: "",
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate ?? "",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
prescriptionEnabled: med.prescriptionEnabled ?? false,
@@ -260,6 +260,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setPendingImage(null);
setPendingImagePreview(null);
setTakenByInput("");
setFieldErrors({});
setFormSaved(false);
const newForm = defaultForm();
setForm(newForm);
+1 -1
View File
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications", { credentials: "include" })
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
+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 { getMedTotal, getPackageSize } from "../types";
@@ -24,7 +24,10 @@ export interface UseRefillReturn {
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockLoosePills: number;
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean;
editStockMedication: Medication | null;
// Actions
loadRefillHistory: (medId: number) => Promise<void>;
@@ -56,7 +59,9 @@ export function useRefill(): UseRefillReturn {
const [showEditStockModal, setShowEditStockModal] = useState(false);
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
const [editStockLoosePills, setEditStockLoosePills] = useState(0);
const [editStockSaving, setEditStockSaving] = useState(false);
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
@@ -132,42 +137,60 @@ export function useRefill(): UseRefillReturn {
if (!selectedMed) return;
setEditStockSaving(true);
try {
// Auto-convert: handle full blister and negative partial blister
let finalFullBlisters = editStockFullBlisters;
let finalPartialPills = editStockPartialBlisterPills;
// Clamp all fields to non-negative values.
let finalFullBlisters = Math.max(0, editStockFullBlisters);
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
if (finalPartialPills >= selectedMed.pillsPerBlister) {
finalFullBlisters += 1;
finalPartialPills = 0;
// Canonicalize blister values: partial overflow becomes additional full blisters.
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
finalPartialPills %= selectedMed.pillsPerBlister;
}
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
if (finalPartialPills < 0 && finalFullBlisters > 0) {
finalFullBlisters -= 1;
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
}
// Structural max = sealed package capacity only (no looseTablets offset).
const structuralMax =
selectedMed.packageType === "bottle"
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
// Ensure we don't go negative
if (finalPartialPills < 0) finalPartialPills = 0;
if (finalFullBlisters < 0) finalFullBlisters = 0;
// What the user says they have RIGHT NOW = the new DB total
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
// The "base" from DB structure (without any stockAdjustment)
// Use getPackageSize() which handles both blister and bottle types correctly
const baseTotal = getPackageSize(selectedMed);
// For blister meds, only sealed pills are capped to package size.
// Loose pills are extra and can be above package size.
const desiredTotal =
selectedMed.packageType === "bottle"
? Math.min(structuralMax, Math.max(0, finalPartialPills))
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
finalLoosePills;
// 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
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`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
body: JSON.stringify(patchBody),
});
if (res.ok) {
// Close edit stock modal via history back
@@ -182,7 +205,7 @@ export function useRefill(): UseRefillReturn {
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
@@ -198,25 +221,51 @@ export function useRefill(): UseRefillReturn {
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
if (!selectedMed) return;
setEditStockMedication(selectedMed);
// Get current stock from coverage (after consumption)
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
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
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
const partialPills = currentStock % selectedMed.pillsPerBlister;
// Bottle correction uses only total pills input.
// For blister, keep loose pills separated from sealed blister/partial counts.
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
setEditStockFullBlisters(fullBlisters);
setEditStockPartialBlisterPills(partialPills);
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
setShowEditStockModal(true);
window.history.pushState({ modal: "editStock" }, "");
}, []);
const closeEditStockModal = useCallback(() => {
if (showEditStockModal) {
let popstateHandled = false;
const handlePopstate = () => {
popstateHandled = true;
};
window.addEventListener("popstate", handlePopstate, { once: true });
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]);
@@ -239,7 +288,10 @@ export function useRefill(): UseRefillReturn {
setEditStockFullBlisters,
editStockPartialBlisterPills,
setEditStockPartialBlisterPills,
editStockLoosePills,
setEditStockLoosePills,
editStockSaving,
editStockMedication,
loadRefillHistory,
submitRefill,
submitStockCorrection,
+10 -5
View File
@@ -46,6 +46,9 @@ export interface Settings {
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
expiryWarningDays: number;
}
@@ -90,6 +93,9 @@ const defaultSettings: Settings = {
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
expiryWarningDays: 30,
};
@@ -224,6 +230,9 @@ export function useSettings(): UseSettingsReturn {
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,
@@ -240,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
body: JSON.stringify(payload),
}).catch(() => null);
const updatedSettings = {
...settingsToSave,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSettingsSaving(false);
setSavedSettings(updatedSettings);
+125 -15
View File
@@ -76,7 +76,7 @@
"emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament kritisch niedrig",
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
"waitingFirstCheck": "Warte auf erste Prüfung",
"waitingFirstCheck": "Warte auf die erste Prüfung",
"type": "Typ",
"typeStock": "Bestand",
"typeIntake": "Einnahme",
@@ -125,7 +125,12 @@
"title": "Medikamentenliste",
"entries": "{{count}} Einträge",
"entries_one": "{{count}} Eintrag",
"entries_other": "{{count}} Einträge"
"entries_other": "{{count}} Einträge",
"markObsolete": "Als obsolet markieren",
"reactivate": "Reaktivieren",
"obsoleteTitle": "Obsolet ({{count}})",
"obsoleteSince": "Beendet",
"started": "Gestartet"
},
"details": {
"packs": "Packungen",
@@ -140,16 +145,24 @@
"deleteModal": {
"title": "Medikament löschen",
"message": "Möchtest du \"{{name}}\" wirklich löschen?"
},
"obsoleteModal": {
"title": "Medikament als obsolet markieren",
"message": "Möchtest du \"{{name}}\" wirklich als obsolet markieren?"
}
},
"form": {
"editEntry": "Medikament bearbeiten",
"editEntry": "Bearbeiten",
"editEntryWithName": "Bearbeiten: {{name}}",
"viewEntry": "Ansehen",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
"sections": {
"general": "Allgemein",
"stock": "Bestand & Dosis",
"prescription": "Rezept"
"stock": "Package",
"prescription": "Rezept",
"prescriptionAndRefill": "Rezept & Nachfüllen",
"schedule": "Einnahme"
},
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
@@ -165,6 +178,7 @@
"loosePills": "Lose Tabletten",
"pillWeight": "Dosis pro Tablette",
"total": "Gesamt (Tabletten)",
"medicationStartDate": "Startdatum der Medikation",
"expiryDate": "Ablaufdatum",
"notes": "Notizen",
"medicationImage": "Medikamentenbild",
@@ -177,6 +191,9 @@
"weight": "z.B. 240",
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
},
"validation": {
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
},
"blisters": {
"title": "Einnahmeplan",
"remind": "Erinnern",
@@ -226,7 +243,7 @@
"stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-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",
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
@@ -265,7 +282,7 @@
"automatic": "Automatisch",
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
"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",
"criticalStockDays": "Kritisch (Tage)",
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
@@ -273,10 +290,22 @@
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
"highStockDays": "Hoch (Tage)",
"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",
"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": {
"title": "Bestands-Erinnerung",
"description": "Bestands-Erinnerungen aktivieren",
@@ -322,6 +351,7 @@
},
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"automaticTaken": "Automatisch eingenommen",
"hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln",
@@ -335,7 +365,8 @@
},
"dose": {
"takenBy": "eingenommen von",
"markAsTaken": "Als eingenommen markieren"
"markAsTaken": "Als eingenommen markieren",
"take": "Nehmen"
},
"auth": {
"login": "Anmelden",
@@ -363,7 +394,7 @@
"checkEmail": "E-Mail überprüfen",
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
"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",
"rememberMe": "Angemeldet bleiben",
"localAccount": "Lokales Konto",
@@ -400,12 +431,13 @@
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} Zeichen"
},
"saved": "Gespeichert",
"saved": "Gespeichert",
"save": "Speichern",
"back": "Zurück",
"cancel": "Abbrechen",
"close": "Schließen",
"edit": "Bearbeiten",
"view": "Ansehen",
"delete": "Löschen",
"remove": "Entfernen",
"reset": "Zurücksetzen",
@@ -419,6 +451,8 @@
"of": "von",
"loose": "lose",
"none": "Kein",
"daily": "täglich",
"everyNDays": "alle {{count}} Tage",
"day": "Tag",
"days": "Tage",
"blister": "Blister",
@@ -430,7 +464,9 @@
"pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max"
"max": "max",
"on": "An",
"off": "Aus"
},
"share": {
"button": "Teilen",
@@ -459,7 +495,7 @@
}
},
"exportImport": {
"title": "Daten Export / Import",
"title": "Datenexport / -import",
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
"exportTitle": "Export",
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
@@ -483,10 +519,13 @@
"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!",
"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",
"exportSuccess": "Daten erfolgreich exportiert",
"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",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
"downloadFilename": "medassist-export"
@@ -513,18 +552,26 @@
"prescription": {
"enabled": "Rezept verfolgen",
"authorizedRefills": "Genehmigte Nachfüllungen",
"remainingRefills": "Verbleibende Nachfüllungen",
"remainingRefills": "Verbleibende Rezept-Nachfüllungen",
"lowThreshold": "Schwelle für Rezept-Erinnerung",
"expiryDate": "Rezeptablauf",
"useForRefill": "Rezept-Nachfüllung verwenden"
},
"editStock": {
"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'.",
"totalPills": "Gesamte Tabletten",
"fullBlisters": "Volle Blister",
"partialBlisterPills": "Angebrochener Blister",
"loosePills": "Lose 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",
"newTotal": "Neuer Bestand",
"difference": "Differenz",
@@ -549,5 +596,68 @@
"copyright": "© {{year}} Daniel Volz",
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
"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."
}
}
+127 -17
View File
@@ -70,10 +70,10 @@
"inDays_one": "in {{days}} day",
"inDays_other": "in {{days}} days",
"noRemindersNeeded": "No reminders needed",
"needRefill": "{{count}} med needs refill",
"needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty",
"needRefill": "{{count}} medication needs refill",
"needRefill_other": "{{count}} medications need refill",
"emptyStock": "{{count}} medication is empty",
"emptyStock_other": "{{count}} medications are empty",
"lowWarning": "{{count}} medication running critically low",
"lowWarning_other": "{{count}} medications running critically low",
"waitingFirstCheck": "Waiting for first check",
@@ -84,10 +84,10 @@
"channelEmail": "Email",
"channelPush": "Push",
"channelBoth": "Email + Push",
"criticalMeds": "{{count}} medication critical",
"criticalMeds_other": "{{count}} medications critical",
"lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low",
"criticalMeds": "{{count}} medication is critical",
"criticalMeds_other": "{{count}} medications are critical",
"lowMeds": "{{count}} medication is low",
"lowMeds_other": "{{count}} medications are low",
"prescriptionNeeds": "Prescription low",
"prescriptionLowMeds": "{{count}} prescription low",
"prescriptionLowMeds_other": "{{count}} prescriptions low",
@@ -125,7 +125,12 @@
"title": "Medication list",
"entries": "{{count}} entries",
"entries_one": "{{count}} entry",
"entries_other": "{{count}} entries"
"entries_other": "{{count}} entries",
"markObsolete": "Mark obsolete",
"reactivate": "Reactivate",
"obsoleteTitle": "Obsolete ({{count}})",
"obsoleteSince": "Stopped",
"started": "Started"
},
"details": {
"packs": "Packs",
@@ -140,16 +145,24 @@
"deleteModal": {
"title": "Delete medication",
"message": "Do you really want to delete \"{{name}}\"?"
},
"obsoleteModal": {
"title": "Mark medication as obsolete",
"message": "Do you really want to mark \"{{name}}\" as obsolete?"
}
},
"form": {
"editEntry": "Edit medication",
"editEntry": "Edit",
"editEntryWithName": "Edit: {{name}}",
"viewEntry": "View",
"newEntry": "New medication",
"badge": "Packs + loose pills",
"sections": {
"general": "General",
"stock": "Stock & Dose",
"prescription": "Prescription"
"stock": "Package",
"prescription": "Prescription",
"prescriptionAndRefill": "Rx & Refill",
"schedule": "Schedule"
},
"commercialName": "Commercial Name",
"genericName": "Generic Name",
@@ -165,6 +178,7 @@
"loosePills": "Loose pills",
"pillWeight": "Dose per pill",
"total": "Total (pills)",
"medicationStartDate": "Medication Start Date",
"expiryDate": "Expiry Date",
"notes": "Notes",
"medicationImage": "Medication Image",
@@ -177,6 +191,9 @@
"weight": "e.g. 240",
"notes": "e.g. Take with food, avoid alcohol... (optional)"
},
"validation": {
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
},
"blisters": {
"title": "Intake schedule",
"remind": "Remind",
@@ -277,6 +294,18 @@
"shareStockStatus": "Show Stock on Shared Links",
"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": {
"title": "Stock Reminder",
"description": "Enable stock reminders",
@@ -322,6 +351,7 @@
},
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"automaticTaken": "Automatically taken",
"hasNotes": "Has notes",
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
"lightMode": "Switch to light mode",
@@ -335,7 +365,8 @@
},
"dose": {
"takenBy": "taken by",
"markAsTaken": "Mark as taken"
"markAsTaken": "Mark as taken",
"take": "Take"
},
"auth": {
"login": "Login",
@@ -400,12 +431,13 @@
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} characters"
},
"saved": "Saved",
"saved": "Saved",
"save": "Save",
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"edit": "Edit",
"view": "View",
"delete": "Delete",
"remove": "Remove",
"reset": "Reset",
@@ -419,6 +451,8 @@
"of": "of",
"loose": "loose",
"none": "None",
"daily": "daily",
"everyNDays": "every {{count}} days",
"day": "day",
"days": "days",
"blister": "blister",
@@ -430,7 +464,9 @@
"pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total",
"max": "max"
"max": "max",
"on": "On",
"off": "Off"
},
"share": {
"button": "Share",
@@ -483,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.",
"confirmImportWarning": "This action cannot be undone!",
"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",
"exportSuccess": "Data exported 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",
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
"downloadFilename": "medassist-export"
@@ -513,18 +552,26 @@
"prescription": {
"enabled": "Track prescription",
"authorizedRefills": "Authorized refills",
"remainingRefills": "Remaining refills",
"remainingRefills": "Remaining prescription refills",
"lowThreshold": "Low-refill reminder threshold",
"expiryDate": "Prescription expiry",
"useForRefill": "Use prescription refill"
},
"editStock": {
"title": "Correct Stock",
"buttonLabel": "Correct Stock/Partial Blister",
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
"totalPills": "Total pills",
"fullBlisters": "Full blisters",
"partialBlisterPills": "Partial blister",
"loosePills": "Loose pills",
"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",
"newTotal": "New total",
"difference": "Difference",
@@ -549,5 +596,68 @@
"copyright": "© {{year}} Daniel Volz",
"madeWith": "Made with ❤️ for better health management",
"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 App from "./App";
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";
ReactDOM.createRoot(document.getElementById("root")!).render(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { DateTimeInput, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { PlannerRow } from "../types";
@@ -158,8 +158,7 @@ export function PlannerPage() {
<form className="planner" onSubmit={runPlanner}>
<label>
{t("planner.from")}
<input
type="datetime-local"
<DateTimeInput
step="60"
value={range.start}
onChange={(e) => setRange({ ...range, start: e.target.value })}
@@ -167,24 +166,21 @@ export function PlannerPage() {
</label>
<label>
{t("planner.until")}
<input
type="datetime-local"
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 className="planner-checkbox">
<input
type="checkbox"
checked={includeUntilStart}
onChange={(e) => setIncludeUntilStart(e.target.checked)}
/>
{t("planner.includeUntilStart")}
<div className="planner-checkbox-row">
<label className="planner-checkbox">
<input
type="checkbox"
checked={includeUntilStart}
onChange={(e) => setIncludeUntilStart(e.target.checked)}
/>
{t("planner.includeUntilStart")}
</label>
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
</span>
</label>
</div>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>
{t("common.reset")}
@@ -210,14 +206,25 @@ export function PlannerPage() {
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
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">
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
{row.medicationName}
</span>
<span data-label={t("planner.table.usage")}>
<strong>{row.plannerUsage}</strong>&nbsp;
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
<span>
<strong>{row.plannerUsage}</strong>&nbsp;
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
</span>
</span>
<span data-label={t("planner.table.blisters")}>
{row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
+64 -10
View File
@@ -1,3 +1,4 @@
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
@@ -65,6 +66,7 @@ export function SchedulePage() {
pastDays,
futureDays,
takenDoses,
isDoseTakenAutomatically,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
@@ -129,7 +131,7 @@ export function SchedulePage() {
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
return (
<div
@@ -139,6 +141,9 @@ export function SchedulePage() {
<div
className="day-divider clickable"
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")}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
@@ -189,27 +194,36 @@ export function SchedulePage() {
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>{" "}
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
<Bell size={14} aria-hidden="true" />
</span>
)}{" "}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
<span
className="person-name clickable"
onClick={() => openUserFilter(person)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
}}
>
{person}
</span>
@@ -220,6 +234,14 @@ export function SchedulePage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
@@ -229,7 +251,8 @@ export function SchedulePage() {
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
@@ -264,6 +287,19 @@ export function SchedulePage() {
}, 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-label">
@@ -329,21 +365,27 @@ export function SchedulePage() {
<div key={dose.id} className="dose-item">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
<span className="dose-usage-main">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
</span>
{med?.pillWeightMg && (
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
)}
</span>
{dose.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
<Bell size={14} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div
@@ -351,7 +393,13 @@ export function SchedulePage() {
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
{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}
</span>
)}
@@ -361,6 +409,11 @@ export function SchedulePage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
</button>
) : (
@@ -370,7 +423,8 @@ export function SchedulePage() {
disabled={isEmpty}
title={t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
+162 -103
View File
@@ -30,8 +30,10 @@ export function SettingsPage() {
handleImportConfirm,
importResult,
setImportResult,
meds,
} = useAppContext();
const hasExistingData = meds.length > 0;
return (
<section className="grid">
{settingsLoading ? (
@@ -43,28 +45,26 @@ export function SettingsPage() {
<div className="card-head">
<h2>{t("settings.language.title")}</h2>
</div>
<div className="setting-section">
<label className="setting-row language-row">
<span className="setting-label">{t("settings.language.select")}</span>
<select
value={i18n.language}
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
className="language-select"
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
</div>
<label className="setting-row language-row">
<span className="setting-label">{t("settings.language.select")}</span>
<select
value={i18n.language}
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
className="language-select"
>
<option value="en">🇬🇧 English</option>
<option value="de">🇩🇪 Deutsch</option>
</select>
</label>
</article>
{/* Notifications */}
@@ -89,7 +89,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
checked={settings.emailStockReminders}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -100,9 +100,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
}
checked={settings.shoutrrrStockReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -116,7 +114,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
checked={settings.emailIntakeReminders}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -127,9 +125,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
}
checked={settings.shoutrrrIntakeReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -143,9 +139,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
}
checked={settings.emailPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -156,11 +150,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled
? settings.shoutrrrPrescriptionReminders
: false
}
checked={settings.shoutrrrPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -373,25 +363,25 @@ export function SettingsPage() {
{settings.emailEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t("settings.email.recipient")}</span>
<div className="input-with-tooltip">
<input
type="email"
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="your@email.com"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
autoComplete="email"
/>
<div className="full">
<span className="field-label">
{t("settings.email.recipient")}
<span
className="info-tooltip"
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
>
</span>
</div>
</label>
</span>
<input
type="email"
value={settings.notificationEmail}
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
placeholder="your@email.com"
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
autoComplete="email"
/>
</div>
</div>
<div className="setting-actions">
<button
@@ -442,23 +432,23 @@ export function SettingsPage() {
{settings.shoutrrrEnabled && (
<>
<div className="setting-group">
<label className="full">
<span className="field-label">{t("settings.push.url")}</span>
<div className="input-with-tooltip">
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t("settings.push.urlPlaceholder")}
/>
<div className="full">
<span className="field-label">
{t("settings.push.url")}
<span
className="info-tooltip"
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
>
</span>
</div>
</label>
</span>
<input
type="text"
value={settings.shoutrrrUrl}
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
placeholder={t("settings.push.urlPlaceholder")}
/>
</div>
</div>
<div className="setting-actions">
<button
@@ -606,7 +596,7 @@ export function SettingsPage() {
<h3>{t("settings.stock.thresholds")}</h3>
</div>
<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="status-chip small danger">{t("status.criticalStock")}</span>
<span
@@ -616,17 +606,15 @@ export function SettingsPage() {
</span>
</span>
<div className="input-with-tooltip">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</div>
</label>
<label
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</div>
<div
className={
settings.lowStockDays <= settings.reminderDaysBefore ||
settings.lowStockDays >= settings.highStockDays
@@ -643,17 +631,15 @@ export function SettingsPage() {
</span>
</span>
<div className="input-with-tooltip">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
</div>
</label>
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
</div>
<div className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label">
<span className="status-chip small high">{t("status.highStock")}</span>
<span
@@ -663,24 +649,76 @@ export function SettingsPage() {
</span>
</span>
<div className="input-with-tooltip">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
</div>
</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
</div>
</div>
{(settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays) && (
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
)}
</div>
</article>
{/* General UI */}
<article className="card">
<div className="card-head">
<h2>{t("settings.timeline.title")}</h2>
</div>
<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-label">
<span>{t("settings.stock.shareStockStatus")}</span>
@@ -697,6 +735,22 @@ export function SettingsPage() {
<span className="toggle-slider"></span>
</label>
</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>
</article>
@@ -734,6 +788,7 @@ export function SettingsPage() {
{t("exportImport.importSuccessDetails", {
medications: importResult.medications,
doses: importResult.doses,
refills: importResult.refills,
shares: importResult.shares,
})}
</span>
@@ -741,6 +796,7 @@ export function SettingsPage() {
<button
type="button"
onClick={() => setImportResult(null)}
aria-label={t("common.close")}
style={{
background: "none",
border: "none",
@@ -751,7 +807,6 @@ export function SettingsPage() {
color: "inherit",
opacity: 0.7,
}}
aria-label="Close"
>
×
</button>
@@ -806,21 +861,25 @@ export function SettingsPage() {
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t("exportImport.confirmImport")}
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
hasExistingData ? (
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</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")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant="danger"
confirmVariant={hasExistingData ? "danger" : "primary"}
/>
)}

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