Compare commits

...

12 Commits

Author SHA1 Message Date
Daniel Volz 47d230ace2 chore: release 1.26.0 (#650) 2026-05-24 16:25:15 +02:00
github-actions[bot] 812b14df03 chore: update test count badges [skip ci] 2026-05-24 12:04:50 +00:00
Daniel Volz c78fc43083 feat(frontend): add intake journal and shared note flows (#648)
* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
2026-05-24 14:00:30 +02:00
Daniel Volz e4a1b449c6 feat(backend): add intake journal APIs and share note support 2026-05-24 13:36:25 +02:00
Daniel Volz 767ae23843 docs: clarify dev hosts and deployment guidance 2026-05-24 13:36:01 +02:00
dependabot[bot] 3eb56885f9 build(deps): bump ws from 8.20.0 to 8.20.1 in /backend (#641)
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 06:33:20 +02:00
dependabot[bot] c5b08b28c1 build(deps): bump the minor-and-patch group in /frontend with 10 updates
Bumps the minor-and-patch group in /frontend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.1.0` | `26.2.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.14.0` | `1.16.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.7` | `17.0.8` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.15.0` | `7.15.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.6.2` | `25.8.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.1` | `6.0.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.5` | `4.1.6` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.12` | `8.0.13` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |


Updates `i18next` from 26.1.0 to 26.2.0
- [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/v26.1.0...v26.2.0)

Updates `lucide-react` from 1.14.0 to 1.16.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.16.0/packages/lucide-react)

Updates `react-i18next` from 17.0.7 to 17.0.8
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.7...v17.0.8)

Updates `react-router-dom` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.15.1/packages/react-router-dom)

Updates `@playwright/test` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `@types/node` from 25.6.2 to 25.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.2/packages/plugin-react)

Updates `@vitest/coverage-v8` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8)

Updates `vite` from 8.0.12 to 8.0.13
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.13/packages/vite)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-05-18 21:36:07 +02:00
dependabot[bot] 1eb7579706 build(deps-dev): bump the minor-and-patch group in /backend with 4 updates
Bumps the minor-and-patch group in /backend with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8), [tsx](https://github.com/privatenumber/tsx) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@types/node` from 25.6.2 to 25.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8)

Updates `tsx` from 4.21.0 to 4.22.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.1)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.22.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-05-18 21:35:58 +02:00
dependabot[bot] e69e46f9fc build(deps): bump brace-expansion from 5.0.5 to 5.0.6 in /backend
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.5 to 5.0.6.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.5...v5.0.6)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 21:35:21 +02:00
dependabot[bot] 1f5dd36b5c build(deps-dev): bump lint-staged in the minor-and-patch group (#637)
Bumps the minor-and-patch group with 1 update: [lint-staged](https://github.com/lint-staged/lint-staged).


Updates `lint-staged` from 17.0.4 to 17.0.5
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.4...v17.0.5)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 17.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-05-18 11:31:26 +02:00
Daniel Volz 545793fdd2 chore: streamline root validation and app loading (#635) 2026-05-16 20:45:26 +02:00
Daniel Volz 2f5fc2d9e9 fix: stabilize medication Playwright gate
* fix: stabilize medication Playwright gate

* fix: satisfy medication Playwright frontend gate
2026-05-15 20:20:18 +02:00
116 changed files with 12626 additions and 1315 deletions
+5
View File
@@ -10,6 +10,8 @@ PUID=1000
PGID=1000
PORT=3000
# Docker Compose quickstart serves the frontend on http://localhost:4174.
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
CORS_ORIGINS=http://localhost:4174
# Server default timezone for scheduled reminders.
@@ -18,8 +20,11 @@ TZ=Europe/Berlin
# Public base URL used for notification action links.
# Required for intake reminder action buttons.
# Use an externally reachable HTTPS URL for remote/self-hosted access.
# PUBLIC_APP_URL=https://medassist.example.com
# If this uses a non-local host, include that origin in CORS_ORIGINS.
# Local Vite development automatically allows this hostname; set
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
# Log level: debug, info, warn, error, silent
LOG_LEVEL=info
+6 -19
View File
@@ -1,26 +1,13 @@
# MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT - Prioritized Constraints
This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for governance, workflow, and skill rules when that file exists in the workspace.
When `AGENTS.md` exists in the workspace, use it as the single source of truth for governance, workflow, and skill rules.
If rules differ between files, follow `AGENTS.md`.
## Required Startup Steps
1. Read `AGENTS.md` first when it exists in the workspace.
2. If `AGENTS.md` exists, identify triggered skills from it and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
4. When work moves into a different thematic area, create or switch to a dedicated local branch or worktree before editing code, and reuse the same branch/worktree for follow-up work inside that same theme.
## Scope
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
+2
View File
@@ -24,6 +24,8 @@ on:
concurrency:
group: docker-build-${{ github.ref }}
# Cancel older runs on the same ref so the shared branch tag stays aligned
# with the newest commit instead of racing older builds against newer ones.
cancel-in-progress: true
# Default minimal permissions
+24 -4
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-697%2F697-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
@@ -157,10 +157,13 @@ Share your medication schedule with others via a public link.
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
- Review validated import contents before replacing current data
- Optionally download a fresh backup before confirming import
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
@@ -188,6 +191,16 @@ docker compose -p medassist-ng up -d
Open `http://localhost:4174` and start tracking your medications.
### Verify Deployment
After the containers start, confirm the stack is actually healthy:
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
# Configuration
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
@@ -206,7 +219,7 @@ Optional but commonly needed:
| Variable | Default | Description |
|----------|---------|-------------|
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
Detailed configuration references:
@@ -218,6 +231,13 @@ Detailed configuration references:
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
```bash
npm run check
npm run build
```
# Acknowledgements
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
@@ -0,0 +1,15 @@
CREATE TABLE `intake_journal` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_tracking_id` integer NOT NULL,
`medication_id` integer NOT NULL,
`scheduled_for` integer NOT NULL,
`note` text NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
@@ -0,0 +1 @@
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -106,6 +106,20 @@
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1778962021119,
"tag": "0015_add_intake_journal",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1779044316043,
"tag": "0016_add_share_allow_journal_notes",
"breakpoints": true
}
]
}
+158 -159
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
"version": "1.23.0",
"version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.23.0",
"version": "1.25.1",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
@@ -32,14 +32,14 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.6.2",
"@types/node": "^25.8.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"tsx": "^4.22.1",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
}
@@ -1862,9 +1862,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1897,9 +1897,9 @@
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -1914,9 +1914,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -1931,9 +1931,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -1948,9 +1948,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -1965,9 +1965,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -1982,9 +1982,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -1999,9 +1999,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -2016,9 +2016,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -2033,9 +2033,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -2050,9 +2050,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -2067,9 +2067,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -2084,9 +2084,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -2101,9 +2101,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -2120,9 +2120,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -2137,9 +2137,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -2154,9 +2154,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -2168,9 +2168,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2218,12 +2218,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/nodemailer": {
@@ -2270,14 +2270,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2291,8 +2291,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.6",
"vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2301,16 +2301,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2319,13 +2319,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2346,9 +2346,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2359,13 +2359,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -2373,14 +2373,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2389,9 +2389,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2399,13 +2399,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2536,9 +2536,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -4168,9 +4168,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -4411,9 +4411,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -4548,14 +4548,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -4564,21 +4564,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/safe-regex2": {
@@ -5032,14 +5032,13 @@
"optional": true
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
@@ -5080,9 +5079,9 @@
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/vary": {
@@ -5095,16 +5094,16 @@
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -5121,7 +5120,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5173,19 +5172,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5213,12 +5212,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -5302,9 +5301,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.25.1",
"version": "1.26.0",
"private": true,
"type": "module",
"scripts": {
@@ -41,14 +41,14 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.6.2",
"@types/node": "^25.8.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"tsx": "^4.22.1",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
},
+12
View File
@@ -76,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS intake_journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
scheduled_for INTEGER NOT NULL,
note TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS notification_action_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
+1
View File
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+22
View File
@@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", {
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
@@ -236,6 +237,27 @@ export const doseTracking = sqliteTable("dose_tracking", {
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
// Intake Journal - Optional owner-scoped note for a tracked dose event
// =============================================================================
export const intakeJournal = sqliteTable("intake_journal", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
doseTrackingId: integer("dose_tracking_id")
.notNull()
.unique()
.references(() => doseTracking.id, { onDelete: "cascade" }),
medicationId: integer("medication_id")
.notNull()
.references(() => medications.id, { onDelete: "cascade" }),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
note: text("note").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Refill History - Tracks when medication stock was refilled
// =============================================================================
+4
View File
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { intakeJournalRoutes } from "./routes/intake-journal.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
import { notificationActionRoutes } from "./routes/notification-actions.js";
@@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
@@ -248,6 +250,7 @@ export async function createApp(options?: {
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -349,6 +352,7 @@ await app.register(plannerRoutes);
await app.register(notificationActionRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
+1 -1
View File
@@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (!user || !user.isActive) {
if (!user?.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
+1 -1
View File
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
// Get user
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
if (!user || !user.isActive) {
if (!user?.isActive) {
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
}
+567 -37
View File
@@ -1,19 +1,26 @@
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js";
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
import {
getIntakeJournalForDoseEvent,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
parseIntakesJson,
parseLocalDateTime,
@@ -32,6 +39,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const shareJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
@@ -56,12 +67,52 @@ const doseReadResponseSchema = {
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
hasJournalNote: { type: "boolean" },
},
},
},
},
} as const;
const shareJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const shareJournalResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: shareJournalEntrySchema,
},
additionalProperties: false,
} as const;
function getValidationErrorMessage(error: z.ZodError): string {
const firstIssue = error.issues[0];
if (!firstIssue) {
@@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string {
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
}
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return false;
}
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
return false;
}
const [medication] = await db
.select()
.from(medications)
@@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return expectedPersons.includes(parsedDose.personSuffix);
}
function getLocalDayStartMs(value: Date | number): number {
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
const todayStart = getLocalDayStartMs(new Date());
const earliestVisible = new Date(todayStart);
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
const latestVisibleExclusive = new Date(todayStart);
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
@@ -226,6 +311,81 @@ async function isDoseOutOfStock(options: {
);
}
async function markDoseSkippedForUser(input: {
userId: number;
doseId: string;
}): Promise<"created" | "updated" | "already_skipped"> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (existing) {
if (existing.dismissed) {
return "already_skipped";
}
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
return "updated";
}
await db.insert(doseTracking).values({
userId: input.userId,
doseId: input.doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
return "created";
}
async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise<boolean> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (!existing?.dismissed) {
return false;
}
const hasRealTakenTimestamp =
existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
if (existing.markedBy !== null || hasRealTakenTimestamp) {
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
return true;
}
await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
return true;
}
function buildSharedJournalEntryDto(input: {
event: NonNullable<Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>>>;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
@@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
protectedPaths: [
/^\/doses\/taken$/,
/^\/doses\/taken\/:doseId$/,
/^\/doses\/dismiss$/,
/^\/doses\/skip$/,
/^\/doses\/skip\/:doseId$/,
],
});
// ---------------------------------------------------------------------------
@@ -383,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /doses/skip - PROTECTED: Mark a single dose as skipped
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/skip",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/skip/:doseId",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
@@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) {
// becomes dismissed, regardless of whether it already has a taken timestamp.
let dismissedCount = 0;
for (const doseId of doseIds) {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
if (existing) {
if (!existing.dismissed) {
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
dismissedCount++;
}
} else {
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
const status = await markDoseSkippedForUser({ userId, doseId });
if (status !== "already_skipped") {
dismissedCount++;
}
}
@@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
// Keep public dose reads scoped to the selected share person and visible schedule window.
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
for (const dose of doses) {
if (await validateShareDoseId(share, dose.doseId)) {
visibleDoses.push(dose);
}
}
const journalDoseTrackingIds = new Set<number>();
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
const journalRows = await db
.select({ doseTrackingId: intakeJournal.doseTrackingId })
.from(intakeJournal)
.where(
and(
eq(intakeJournal.userId, share.userId),
inArray(
intakeJournal.doseTrackingId,
visibleDoses.map((dose) => dose.id)
)
)
);
for (const row of journalRows) {
journalDoseTrackingIds.add(row.doseTrackingId);
}
}
return {
doses: doses.map((d) => ({
doses: visibleDoses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
hasJournalNote: journalDoseTrackingIds.has(d.id),
})),
};
}
);
app.get<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
}
const normalizedNote = parsed.data.note.trim();
if (normalizedNote.length === 0) {
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
}
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId: share.userId,
doseId,
note: normalizedNote,
});
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses/skip",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const { doseId } = parsed.data;
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
request.log.info(
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/skip/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
await undoDoseSkippedForUser({ userId: share.userId, doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
@@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
@@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing) {
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
@@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
@@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
request.log.info(
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
@@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
+373 -179
View File
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
listIntakeJournalExportPayloadsForUser,
restoreIntakeJournalForImportedDose,
} from "../services/intake-journal-export.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.5";
const EXPORT_VERSION = "1.6";
// =============================================================================
// Zod Schemas for Import Validation
@@ -91,6 +95,9 @@ const doseHistorySchema = z.object({
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")
journalNote: z.string().nullable().optional(),
journalCreatedAt: z.string().nullable().optional(),
journalUpdatedAt: z.string().nullable().optional(),
});
const refillHistoryExportSchema = z.object({
@@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
allowJournalNotes: z.boolean().default(false),
expiresAt: z.string().nullable().optional(), // ISO datetime
regenerateToken: z.boolean().default(true),
});
@@ -140,8 +148,6 @@ const settingsSchemaBase = z.object({
shareMedicationOverview: z.boolean().default(false),
});
const exportSettingsSchema = settingsSchemaBase.optional();
const importSettingsSchema = settingsSchemaBase
.extend({
// Accept the removed field from legacy exports so old backups still import,
@@ -197,7 +203,7 @@ const importBodyOpenApiSchema = {
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
version: "1.6",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
@@ -217,13 +223,72 @@ const importBodyOpenApiSchema = {
],
},
],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-03-11T08:00:00.000Z",
takenAt: "2026-03-11T08:03:00.000Z",
markedBy: "Daniel",
takenSource: "manual",
dismissed: false,
takenByPerson: "Daniel",
journalNote: "Took after breakfast.",
journalUpdatedAt: "2026-03-11T08:05:00.000Z",
},
],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
const importPreviewResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
preview: {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
incoming: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
journalEntries: { type: "integer" },
imageCount: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
current: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
warnings: {
type: "object",
properties: {
replacesExistingData: { type: "boolean" },
regeneratesShareLinks: { type: "boolean" },
containsImages: { type: "boolean" },
containsSensitiveData: { type: "boolean" },
},
},
},
},
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
@@ -297,7 +362,7 @@ function imageToBase64(imageUrl: string | null): string | null {
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
if (!base64 || !base64.startsWith("data:")) return null;
if (!base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
@@ -323,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
}
}
function removeFileIfPresent(filePath: string): string | null {
if (!existsSync(filePath)) {
return null;
}
try {
unlinkSync(filePath);
return null;
} catch (error) {
return error instanceof Error ? error.message : "Unknown file removal error";
}
}
function buildImportPreview(
importData: z.infer<typeof importDataSchema>,
currentData: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
}
) {
const journalEntries = importData.doseHistory.filter(
(dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
).length;
const imageCount = importData.medications.filter(
(med) => typeof med.image === "string" && med.image.startsWith("data:")
).length;
return {
version: importData.version,
exportedAt: importData.exportedAt,
includeSensitiveData: importData.includeSensitiveData,
incoming: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
shareLinks: importData.shareLinks.length,
journalEntries,
imageCount,
hasSettings: Boolean(importData.settings),
},
current: currentData,
warnings: {
replacesExistingData:
currentData.medications > 0 ||
currentData.doseHistory > 0 ||
currentData.refillHistory > 0 ||
currentData.shareLinks > 0 ||
currentData.hasSettings,
regeneratesShareLinks: importData.shareLinks.length > 0,
containsImages: imageCount > 0,
containsSensitiveData: importData.includeSensitiveData,
},
};
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
@@ -444,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
const exportDoseHistory = doses
.map((dose) => {
@@ -486,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
...journalPayloadsByDoseTrackingId.get(dose.id),
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
@@ -544,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
@@ -619,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /import/preview - Validate and summarize import data without writing
// ---------------------------------------------------------------------------
app.post(
"/import/preview",
{
config: {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024,
schema: {
body: importBodyOpenApiSchema,
response: {
200: importPreviewResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
await Promise.all([
db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
]);
return {
success: true,
preview: buildImportPreview(parsed.data, {
medications: existingMeds.length,
doseHistory: existingDoseHistory.length,
refillHistory: existingRefillHistory.length,
shareLinks: existingShareLinks.length,
hasSettings: existingSettings.length > 0,
}),
};
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
@@ -651,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
500: genericErrorSchema,
},
},
},
@@ -668,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
// Existing image files are removed only after the DB import commits.
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try {
unlinkSync(imagePath);
} catch {
/* ignore */
const oldImagePaths = existingMeds
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
.filter((path): path is string => path !== null);
const newImagePaths: string[] = [];
try {
await db.transaction(async (tx) => {
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
await tx.delete(medications).where(eq(medications.userId, userId));
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const intakesJson = JSON.stringify(normalizedSchedules);
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await tx
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
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 || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
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,
})
.returning();
exportIdToNewId.set(med._exportId, inserted.id);
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
}
}
}
}
}
// 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));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue;
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
const scheduledFor = new Date(dose.scheduledTime);
const timestampMs = scheduledFor.getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const [insertedDose] = await tx
.insert(doseTracking)
.values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
})
.returning({ id: doseTracking.id });
const intakesJson = JSON.stringify(normalizedSchedules);
await restoreIntakeJournalForImportedDose({
userId,
doseTrackingId: insertedDose.id,
medicationId: newMedId,
scheduledFor,
journalNote: dose.journalNote,
journalCreatedAt: dose.journalCreatedAt,
journalUpdatedAt: dose.journalUpdatedAt,
database: tx,
});
}
// Check if any schedule has remind enabled
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
if (importData.settings) {
await tx.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
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",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
const [inserted] = await db
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
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 || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
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();
for (const share of importData.shareLinks) {
await tx.insert(shareTokens).values({
userId,
token: randomBytes(8).toString("hex"),
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue;
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
await tx.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
}
});
} catch (error) {
for (const imagePath of newImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
}
}
request.log.error({ err: error }, "[Import] Failed to import data");
return reply.status(500).send({ error: "Import failed" });
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
// 5. Import settings
if (importData.settings) {
// Legacy exports may still contain shareStockStatus. The current app no longer
// uses that setting, so imports accept it for compatibility and then ignore it.
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
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",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// 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 ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
for (const imagePath of oldImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
}
}
return {
+373
View File
@@ -0,0 +1,373 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
deleteIntakeJournalForDoseEvent,
getIntakeJournalForDoseEvent,
isTrackedDoseIdFormat,
listIntakeJournalEntriesForUser,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdParamsSchema = {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
} as const;
const intakeJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const intakeJournalEventResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: intakeJournalEntrySchema,
},
additionalProperties: false,
} as const;
const intakeJournalHistoryResponseSchema = {
type: "object",
required: ["entries"],
properties: {
entries: {
type: "array",
items: intakeJournalEntrySchema,
},
},
additionalProperties: false,
} as const;
const intakeJournalHistoryQuerySchema = z.object({
medicationId: z.coerce.number().int().positive().optional(),
from: z.string().trim().min(1).optional(),
to: z.string().trim().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
});
const intakeJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
function getValidationErrorMessage(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return "Invalid request payload";
}
return issue.message;
}
function parseOptionalDate(value: string | undefined): Date | null {
if (!value) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
function buildJournalEntryDto(input: {
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
? T extends null
? never
: T
: never;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
export async function intakeJournalRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
"/intake-journal",
{
schema: {
tags: ["intake-journal"],
summary: "List intake journal history for the current owner",
security: intakeJournalEndpointSecurity,
querystring: {
type: "object",
properties: {
medicationId: { type: "integer", minimum: 1 },
from: { type: "string", format: "date-time" },
to: { type: "string", format: "date-time" },
limit: { type: "integer", minimum: 1, maximum: 200 },
},
},
response: {
200: intakeJournalHistoryResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const from = parseOptionalDate(parsed.data.from);
if (parsed.data.from && !from) {
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
}
const to = parseOptionalDate(parsed.data.to);
if (parsed.data.to && !to) {
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
}
if (from && to && from.getTime() > to.getTime()) {
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
}
const entries = await listIntakeJournalEntriesForUser({
userId,
medicationId: parsed.data.medicationId,
from: from ?? undefined,
to: to ?? undefined,
limit: parsed.data.limit,
});
return {
entries: entries.map((entry) => ({
doseTrackingId: entry.doseTrackingId,
doseId: entry.doseId,
medicationId: entry.medicationId,
medicationName: entry.medicationName,
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
dismissed: entry.dismissed,
takenSource: entry.takenSource,
markedBy: entry.markedBy,
note: entry.note,
updatedAt: entry.updatedAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
})),
};
}
);
app.get<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Get intake journal context for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Create or update an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId,
doseId,
note: parsed.data.note,
});
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Delete an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean" },
},
additionalProperties: false,
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
if (!deleted) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
return { success: true };
}
);
}
+28 -5
View File
@@ -45,12 +45,24 @@ type PlannerRow = {
type SendEmailBody = {
email: string;
from: string;
until: string;
from?: string;
until?: string;
startDate?: string;
endDate?: string;
rows: PlannerRow[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
const startDate = body.startDate ?? body.from;
const endDate = body.endDate ?? body.until;
if (!startDate || !endDate) {
return null;
}
return { startDate, endDate };
}
type LowStockItem = {
name: string;
medsLeft: number;
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
email: { type: "string" },
from: { type: "string" },
until: { type: "string" },
startDate: { type: "string", format: "date-time" },
endDate: { type: "string", format: "date-time" },
language: { type: "string" },
rows: { type: "array", items: plannerRowSchema },
},
example: {
email: "daniel@example.com",
startDate: "2026-03-11T00:00:00.000Z",
endDate: "2026-04-11T00:00:00.000Z",
from: "2026-03-11",
until: "2026-04-11",
language: "en",
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
},
},
async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
const { email, rows, language: bodyLanguage } = request.body;
const resolvedDateRange = resolvePlannerDateRange(request.body);
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
}
if (!resolvedDateRange) {
return reply.status(400).send({ error: "Missing planner date range" });
}
const { startDate, endDate } = resolvedDateRange;
// Load user settings for notification channels
const userId = await getUserId(request);
const activeMeds = await db
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
new Date(from).toLocaleDateString(locale, {
new Date(startDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
})
);
const untilDate = escapeHtml(
new Date(until).toLocaleDateString(locale, {
new Date(endDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
+92 -9
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, eq, gte, lt } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -12,10 +12,42 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
});
const reportDataSchema = z
.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
})
.superRefine((value, ctx) => {
const hasStartDate = typeof value.startDate === "string";
const hasEndDate = typeof value.endDate === "string";
if (hasStartDate !== hasEndDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "startDate and endDate must be provided together",
path: hasStartDate ? ["endDate"] : ["startDate"],
});
return;
}
if (!hasStartDate || !hasEndDate) {
return;
}
const startDateValue = value.startDate!;
const endDateValue = value.endDate!;
const startDate = new Date(startDateValue);
const endDate = new Date(endDateValue);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid date range",
path: ["endDate"],
});
}
});
const reportDataBodyOpenApiSchema = {
type: "object",
@@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
startDate: {
type: "string",
format: "date-time",
},
endDate: {
type: "string",
format: "date-time",
},
takenByFilter: {
type: "array",
maxItems: 50,
@@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = {
},
example: {
medicationIds: [1, 3, 5],
startDate: "2026-05-01T00:00:00.000Z",
endDate: "2026-06-01T00:00:00.000Z",
takenByFilter: ["Daniel"],
},
} as const;
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getPersonTagKey(value: string): string {
return value.trim().toLocaleLowerCase();
}
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
if (!takenByFilter) return true;
const parts = doseId.split("-");
if (parts.length < 4) return false;
const takenBy = parts.at(-1)?.trim();
if (!takenBy) return false;
return takenByFilter.has(takenBy);
return takenByFilter.has(getPersonTagKey(takenBy));
}
function getDoseScheduledAtMs(doseId: string): number | null {
const match = trackedDoseIdPattern.exec(doseId);
if (!match) {
return null;
}
const scheduledAtMs = Number.parseInt(match[3], 10);
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
}
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
if (!range) {
return true;
}
if (timestampMs === null) {
return false;
}
return timestampMs >= range.startMs && timestampMs < range.endMs;
}
const reportDataResponseSchema = {
@@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds, takenByFilter } = parsed.data;
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
const normalizedTakenByFilter = takenByFilter?.length
? new Set(takenByFilter.map((value) => value.trim()))
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
: null;
const dateRange =
startDate && endDate
? {
startMs: new Date(startDate).getTime(),
endMs: new Date(endDate).getTime(),
}
: null;
// Verify all medications belong to this user
const userMeds = await db
@@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
@@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) {
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
if (dateRange) {
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
}
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
.where(and(...refillFilters));
result[medId] = {
dosesTaken: takenDoses.length,
+193 -13
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -15,6 +15,7 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -28,6 +29,11 @@ import {
const createShareSchema = z.object({
takenBy: z.string().min(1, "takenBy is required"),
scheduleDays: z.number().int().min(1).max(365).default(30),
expiryDays: z
.union([z.number().int().min(1).max(365), z.null()])
.optional()
.default(null),
allowJournalNotes: z.boolean().optional().default(false),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
const shareTokenPattern = /^[a-f0-9]{16}$/;
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
if (value == null) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
const numericValue = typeof value === "number" ? value : Number(value);
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
} catch {
return null;
}
}
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
if (expiryDays == null) {
return null;
}
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
}
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
const isoValue = toIsoTimestamp(value);
return isoValue != null && new Date(isoValue).getTime() < Date.now();
}
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
allowJournalNotes: { type: "boolean", default: false },
expiryDays: {
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
default: null,
},
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
expiryDays: 30,
},
} as const;
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
allowJournalNotes: { type: "boolean" },
},
} as const;
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
},
} as const;
const shareListResponseSchema = {
type: "object",
properties: {
shareLinks: {
type: "array",
items: {
type: "object",
properties: {
token: { type: "string" },
takenBy: { type: "string" },
scheduleDays: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
allowJournalNotes: { type: "boolean" },
shareUrl: { type: "string" },
},
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
},
},
},
required: ["shareLinks"],
} as const;
const ownerTokenParamsSchema = {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
allowJournalNotes: { type: "boolean" },
expiresAt: { type: ["string", "null"] },
},
},
@@ -390,7 +476,8 @@ export async function shareRoutes(app: FastifyInstance) {
});
}
const { takenBy, scheduleDays } = parsed.data;
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
const expiresAt = resolveExpiryDate(expiryDays);
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
const existingTokenRef = redactTokenForLog(existingShare.token);
await db
.update(shareTokens)
.set({ scheduleDays, expiresAt, allowJournalNotes })
.where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
const token = randomBytes(8).toString("hex");
const tokenRef = redactTokenForLog(token);
await db.insert(shareTokens).values({
userId,
token,
takenBy,
scheduleDays,
expiresAt: null,
allowJournalNotes,
expiresAt,
});
request.log.info(
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
);
// ---------------------------------------------------------------------------
// GET /share - PROTECTED: List active share links for current owner
// ---------------------------------------------------------------------------
app.get(
"/share",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: shareListResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const shares = await db
.select()
.from(shareTokens)
.where(eq(shareTokens.userId, userId))
.orderBy(desc(shareTokens.createdAt));
return {
shareLinks: shares
.filter((share) => !isExpiredTimestamp(share.expiresAt))
.map((share) => ({
token: share.token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
expiresAt: toIsoTimestamp(share.expiresAt),
allowJournalNotes: share.allowJournalNotes ?? false,
shareUrl: `/share/${share.token}`,
})),
};
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token - PROTECTED: Revoke an existing share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string } }>(
"/share/:token",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
params: ownerTokenParamsSchema,
response: {
204: { type: "null" },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const [share] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
if (!share) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
request.log.info(
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
);
return reply.status(204).send();
}
);
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
@@ -0,0 +1,90 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { intakeJournal } from "../db/schema.js";
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
export type IntakeJournalExportPayload = {
journalNote: string;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
};
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
if (!value) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
} catch {
return null;
}
}
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
if (!value) {
return fallback;
}
try {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} catch {
return fallback;
}
}
export async function listIntakeJournalExportPayloadsForUser(
userId: number
): Promise<Map<number, IntakeJournalExportPayload>> {
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
return new Map(
rows.map((row) => [
row.doseTrackingId,
{
journalNote: row.note,
journalCreatedAt: toIsoStringOrNull(row.createdAt),
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
},
])
);
}
export async function restoreIntakeJournalForImportedDose(input: {
userId: number;
doseTrackingId: number;
medicationId: number;
scheduledFor: Date;
journalNote?: string | null;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
database?: IntakeJournalWriteDatabase;
}): Promise<boolean> {
const normalizedNote = input.journalNote?.trim() ?? "";
if (normalizedNote.length === 0) {
return false;
}
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
const database = input.database ?? db;
await database.insert(intakeJournal).values({
userId: input.userId,
doseTrackingId: input.doseTrackingId,
medicationId: input.medicationId,
scheduledFor: input.scheduledFor,
note: normalizedNote,
createdAt,
updatedAt,
});
return true;
}
@@ -0,0 +1,332 @@
import { and, desc, eq, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import type { DoseTrackingSource } from "./dose-tracking-service.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
type MedicationTimingRow = {
id: number;
name: string | null;
genericName: string | null;
intakesJson: string;
usageJson: string;
everyJson: string;
startJson: string;
intakeRemindersEnabled: boolean;
};
export type ResolvedTrackedDoseEvent = {
doseTrackingId: number;
userId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
personSuffix: string | null;
};
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
export type IntakeJournalHistoryEntry = {
id: number;
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
note: string;
createdAt: Date;
updatedAt: Date;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) {
return null;
}
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
export function isTrackedDoseIdFormat(doseId: string): boolean {
return parseDoseId(doseId) !== null;
}
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
const commercialName = medication.name?.trim() ?? "";
if (commercialName.length > 0) {
return commercialName;
}
const genericName = medication.genericName?.trim() ?? "";
if (genericName.length > 0) {
return genericName;
}
return `Medication #${medication.id}`;
}
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled
);
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return new Date(parsedDose.timestampMs);
}
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
);
}
export async function resolveTrackedDoseEventForUser(input: {
userId: number;
doseId: string;
}): Promise<ResolvedTrackedDoseEvent | null> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return null;
}
const [event] = await db
.select({
doseTrackingId: doseTracking.id,
userId: doseTracking.userId,
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
medicationId: medications.id,
medicationName: medications.name,
medicationGenericName: medications.genericName,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(doseTracking)
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
.limit(1);
if (!event) {
return null;
}
const scheduledFor = resolveScheduledFor(parsedDose, {
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
intakesJson: event.intakesJson,
usageJson: event.usageJson,
everyJson: event.everyJson,
startJson: event.startJson,
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
});
return {
doseTrackingId: event.doseTrackingId,
userId: event.userId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: getMedicationDisplayName({
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
}),
scheduledFor,
takenAt: event.takenAt,
markedBy: event.markedBy,
takenSource: event.takenSource as DoseTrackingSource,
dismissed: event.dismissed ?? false,
personSuffix: parsedDose.personSuffix,
};
}
export async function getIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
}): Promise<IntakeJournalEntry | null> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return null;
}
const [journalEntry] = await db
.select()
.from(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
.limit(1);
return journalEntry ?? null;
}
export async function upsertIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
note: string;
}): Promise<IntakeJournalEntry | null> {
const normalizedNote = input.note.trim();
if (normalizedNote.length === 0) {
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
return null;
}
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
if (!event) {
return null;
}
const now = new Date();
await db
.insert(intakeJournal)
.values({
userId: input.userId,
doseTrackingId: event.doseTrackingId,
medicationId: event.medicationId,
scheduledFor: event.scheduledFor,
note: normalizedNote,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: intakeJournal.doseTrackingId,
set: {
userId: input.userId,
medicationId: event.medicationId,
note: normalizedNote,
updatedAt: now,
},
});
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
}
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return false;
}
await db
.delete(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
return true;
}
export async function listIntakeJournalEntriesForUser(input: {
userId: number;
medicationId?: number;
from?: Date;
to?: Date;
limit?: number;
}): Promise<IntakeJournalHistoryEntry[]> {
const filters = [eq(intakeJournal.userId, input.userId)];
if (typeof input.medicationId === "number") {
filters.push(eq(intakeJournal.medicationId, input.medicationId));
}
if (input.from) {
filters.push(gte(intakeJournal.scheduledFor, input.from));
}
if (input.to) {
filters.push(lte(intakeJournal.scheduledFor, input.to));
}
const rows = await db
.select({
id: intakeJournal.id,
doseTrackingId: intakeJournal.doseTrackingId,
doseId: doseTracking.doseId,
medicationId: intakeJournal.medicationId,
medicationName: medications.name,
medicationGenericName: medications.genericName,
scheduledFor: intakeJournal.scheduledFor,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
note: intakeJournal.note,
createdAt: intakeJournal.createdAt,
updatedAt: intakeJournal.updatedAt,
})
.from(intakeJournal)
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
.where(and(...filters))
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
.limit(input.limit ?? 100);
return rows.map((row) => ({
id: row.id,
doseTrackingId: row.doseTrackingId,
doseId: row.doseId,
medicationId: row.medicationId,
medicationName: getMedicationDisplayName({
id: row.medicationId,
name: row.medicationName,
genericName: row.medicationGenericName,
}),
scheduledFor: row.scheduledFor,
takenAt: row.takenAt,
markedBy: row.markedBy,
takenSource: row.takenSource as DoseTrackingSource,
dismissed: row.dismissed ?? false,
note: row.note,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
+240 -6
View File
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
@@ -78,20 +79,30 @@ async function insertMedication(options: {
start?: string;
}) {
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? [];
const intakeTakenBy = takenBy[0] ?? null;
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
args: [
options.id,
options.userId,
JSON.stringify(options.takenBy ?? []),
JSON.stringify(takenBy),
options.packCount ?? 1,
options.looseTablets ?? 0,
intakeStart,
"[]",
JSON.stringify([
{
usage: 1,
every: 1,
start: intakeStart,
takenBy: intakeTakenBy,
intakeRemindersEnabled: false,
},
]),
],
});
}
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
});
}
async function _insertShareToken(userId: number, token: string, takenBy: string) {
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
args: [userId, token, takenBy],
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
});
}
function buildLocalDoseStart(hours = 8): string {
const start = new Date();
start.setHours(hours, 0, 0, 0);
const year = start.getFullYear();
const month = String(start.getMonth() + 1).padStart(2, "0");
const day = String(start.getDate()).padStart(2, "0");
const hour = String(start.getHours()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:00:00.000`;
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
});
});
describe("single-dose skip routes", () => {
it("marks a single owner dose as skipped through the frontend route", async () => {
const doseId = "1-0-1735344000000";
const response = await app.inject({
method: "POST",
url: "/doses/skip",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
});
it("undoes a skipped-only owner dose through the frontend route", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
const response = await app.inject({
method: "DELETE",
url: `/doses/skip/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(result.rows[0].count)).toBe(0);
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
]);
});
});
describe("shared single-dose skip routes", () => {
it("marks and undoes a visible shared dose as skipped", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 6,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "share-skip-token", "Max", false);
const doseId = `6-0-${new Date(start).getTime()}-Max`;
const skipResponse = await app.inject({
method: "POST",
url: "/share/share-skip-token/doses/skip",
payload: { doseId },
});
expect(skipResponse.statusCode).toBe(200);
expect(skipResponse.json()).toEqual({ success: true });
const skippedRows = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
const undoResponse = await app.inject({
method: "DELETE",
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
});
expect(undoResponse.statusCode).toBe(200);
expect(undoResponse.json()).toEqual({ success: true });
const remainingRows = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(remainingRows.rows[0].count)).toBe(0);
});
});
describe("Shared journal notes", () => {
it("rejects shared journal access when the share link does not allow notes", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 7,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-no-notes", "Max", false);
const doseId = `7-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const response = await app.inject({
method: "GET",
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({
error: "Journal notes are not enabled for this share link",
code: "NOT_ENABLED",
});
});
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 8,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-with-notes", "Max", true);
const doseId = `8-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const initialResponse = await app.inject({
method: "GET",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(initialResponse.statusCode).toBe(200);
expect(initialResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
markedBy: "Max",
note: null,
})
);
const initialDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(initialDosesResponse.statusCode).toBe(200);
expect(initialDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: false,
}),
]);
const saveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: "Shared note from Max" },
});
expect(saveResponse.statusCode).toBe(200);
expect(saveResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
note: "Shared note from Max",
})
);
const savedDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(savedDosesResponse.statusCode).toBe(200);
expect(savedDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: true,
}),
]);
const blankSaveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: " " },
});
expect(blankSaveResponse.statusCode).toBe(400);
expect(blankSaveResponse.json()).toEqual({
error: "Journal note cannot be empty",
code: "EMPTY_NOTE",
});
const deleteResponse = await app.inject({
method: "DELETE",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(deleteResponse.statusCode).toBe(403);
expect(deleteResponse.json()).toEqual({
error: "Shared links cannot delete journal notes",
code: "DELETE_NOT_ALLOWED",
});
const journalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
args: [userId, 8],
});
expect(journalRows.rows).toHaveLength(1);
expect(journalRows.rows[0].note).toBe("Shared note from Max");
});
});
});
+199 -14
View File
@@ -3,6 +3,7 @@
* These tests import the actual route handlers for real coverage.
*/
import { existsSync, unlinkSync } from "node:fs";
import cookie from "@fastify/cookie";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
// Dynamic import inside hoisted block
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return { testClient: client, testDb: db };
return { testClient: client, testDb: db, testDbPath: dbPath };
});
// Mock modules using the hoisted db
@@ -171,6 +175,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -184,6 +189,19 @@ async function createSchema(client: Client) {
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS intake_journal (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_tracking_id integer NOT NULL UNIQUE,
medication_id integer NOT NULL,
scheduled_for integer NOT NULL,
note text NOT NULL,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
@@ -204,6 +222,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM intake_journal");
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
@@ -222,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise<number> {
}
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
const start = new Date(visibleDoseTimestampMs()).toISOString();
const result = await client.execute({
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
args: [userId, name, JSON.stringify(takenBy)],
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
});
return result.rows[0].id as number;
}
@@ -237,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string,
});
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
// =============================================================================
// E2E Tests with Real Routes
// =============================================================================
@@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
@@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should mark dose via share link using real route", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_share_token_456";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const response = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
@@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
});
it("should unmark dose via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_delete_dose_token";
await createShareToken(testClient, userId, "Daniel", token);
// First mark the dose
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
@@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should return already marked message for duplicate dose", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_duplicate_token";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
// Mark the dose first time
await app.inject({
@@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
// ---------------------------------------------------------------------------
describe("Share token management", () => {
it("should list active share links for the owner", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 90,
},
});
expect(createResponse.statusCode).toBe(200);
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const data = listResponse.json();
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].takenBy).toBe("Daniel");
});
it("should revoke an active share link", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
},
});
const { token } = createResponse.json();
const revokeResponse = await app.inject({
method: "DELETE",
url: `/share/${token}`,
});
expect(revokeResponse.statusCode).toBe(204);
const publicResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(publicResponse.statusCode).toBe(404);
});
it("should create share token with custom scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
expect(data.expiresAt).toBeDefined();
});
it("should create a share token with an expiry and keep it in the active owner list", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
expiryDays: 7,
},
});
expect(createResponse.statusCode).toBe(200);
const created = createResponse.json();
expect(created.expiresAt).toBeTruthy();
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const listData = listResponse.json();
expect(listData.shareLinks).toHaveLength(1);
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
});
it("should return validation error for invalid scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
describe("Share token dose routes", () => {
it("should get taken doses via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "get-doses-token";
await createShareToken(testClient, userId, "Daniel", token);
// Insert a dose directly
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, "1-0-1735344000000", "Daniel"],
args: [userId, doseId, "Daniel"],
});
const response = await app.inject({
@@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
expect(data.doses[0].doseId).toBe(doseId);
expect(data.doses[0].markedBy).toBe("Daniel");
});
@@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
});
describe("Real /import routes", () => {
it("should preview import data without mutating existing user data", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Existing Med",
packCount: 2,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const previewPayload = {
version: "1.6",
exportedAt: new Date().toISOString(),
includeSensitiveData: true,
medications: [
{
_exportId: "med-1",
name: "Imported Med",
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2025-01-01T08:00:00.000Z",
takenAt: "2025-01-01T08:03:00.000Z",
journalNote: "after breakfast",
},
],
};
const previewResponse = await app.inject({
method: "POST",
url: "/import/preview",
payload: previewPayload,
});
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.json()).toMatchObject({
success: true,
preview: {
version: "1.6",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 1,
shareLinks: 1,
journalEntries: 1,
hasSettings: true,
},
current: {
medications: 1,
hasSettings: false,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsSensitiveData: true,
},
},
});
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Existing Med");
});
it("should import medications from export format", async () => {
const importData = {
version: "1.0",
@@ -0,0 +1,453 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import sensible from "@fastify/sensible";
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";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
PUBLIC_APP_URL: "https://app.example.com",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { exportRoutes } = await import("../routes/export.js");
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
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 api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
const start = options.start ?? "2026-02-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, 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: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
1,
1,
10,
0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedTrackedDose(options: {
userId: number;
doseId: string;
takenAt: Date;
markedBy?: string | null;
dismissed?: boolean;
}) {
const result = await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.doseId,
Math.floor(options.takenAt.getTime() / 1000),
options.markedBy ?? null,
"manual",
options.dismissed ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
describe("Intake journal routes", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("keeps journal CRUD/history owner-scoped across route access", async () => {
const ownerId = await createUser("journal-owner");
const otherId = await createUser("journal-other");
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
const ownerStart = "2026-02-01T08:00:00.000Z";
const otherStart = "2026-02-02T09:00:00.000Z";
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
await seedTrackedDose({
userId: ownerId,
doseId: ownerDoseId,
takenAt: new Date("2026-02-01T08:05:00.000Z"),
markedBy: "Daniel",
});
await seedTrackedDose({
userId: otherId,
doseId: otherDoseId,
takenAt: new Date("2026-02-02T09:05:00.000Z"),
markedBy: "Maria",
});
const ownerPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
payload: { note: "Took after breakfast." },
});
expect(ownerPutResponse.statusCode).toBe(200);
expect(ownerPutResponse.json().entry).toEqual(
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
scheduledFor: expect.stringContaining("T08:00:00"),
note: "Took after breakfast.",
})
);
const otherPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: otherCookie },
payload: { note: "Different owner note." },
});
expect(otherPutResponse.statusCode).toBe(200);
const ownerHistoryResponse = await app.inject({
method: "GET",
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
headers: { cookie: ownerCookie },
});
expect(ownerHistoryResponse.statusCode).toBe(200);
expect(ownerHistoryResponse.json().entries).toEqual([
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
note: "Took after breakfast.",
markedBy: "Daniel",
}),
]);
const otherEventResponse = await app.inject({
method: "GET",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(otherEventResponse.statusCode).toBe(404);
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
const deleteResponse = await app.inject({
method: "DELETE",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toEqual({ success: true });
const emptyHistoryResponse = await app.inject({
method: "GET",
url: "/intake-journal",
headers: { cookie: ownerCookie },
});
expect(emptyHistoryResponse.statusCode).toBe(200);
expect(emptyHistoryResponse.json().entries).toEqual([]);
});
it("preserves journal metadata through authenticated export and import", async () => {
const userId = await createUser("journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
const start = "2026-02-03T07:30:00.000Z";
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
const doseTrackingId = await seedTrackedDose({
userId,
doseId,
takenAt: new Date("2026-02-03T07:33:00.000Z"),
markedBy: "Daniel",
});
const createdAt = new Date("2026-02-03T07:40:00.000Z");
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
await testClient.execute({
sql: `INSERT INTO intake_journal (
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
doseTrackingId,
medicationId,
Math.floor(new Date(start).getTime() / 1000),
"Roundtrip journal note",
Math.floor(createdAt.getTime() / 1000),
Math.floor(updatedAt.getTime() / 1000),
],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.doseHistory).toHaveLength(1);
expect(exportBody.doseHistory[0]).toEqual(
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
})
);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const reExportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(reExportResponse.statusCode).toBe(200);
expect(reExportResponse.json().doseHistory).toEqual([
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
}),
]);
const restoredJournalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
args: [userId],
});
expect(restoredJournalRows.rows).toHaveLength(1);
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
});
it("preserves the shared journal-note permission through authenticated export and import", async () => {
const userId = await createUser("share-journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
await testClient.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.shareLinks).toEqual([
expect.objectContaining({
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
regenerateToken: true,
}),
]);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const shareRows = await testClient.execute({
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
args: [userId],
});
expect(shareRows.rows).toHaveLength(1);
expect(shareRows.rows[0]).toEqual(
expect.objectContaining({
taken_by: "Daniel",
schedule_days: 14,
allow_journal_notes: 1,
})
);
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
});
it("keeps existing data when import fails inside the replacement transaction", async () => {
const userId = await createUser("import-rollback");
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
await seedMedication({ userId, name: "Existing Rollback Med" });
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: {
version: "1.6",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Rollback Med",
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-02-04T08:00:00.000Z",
takenAt: "not-a-date",
},
],
},
});
expect(importResponse.statusCode).toBe(500);
const medicationRows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
args: [userId],
});
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
});
});
+30 -10
View File
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -195,6 +196,16 @@ async function clearData(client: Client) {
await client.execute("DELETE FROM sqlite_sequence");
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
function visibleDoseStartIso(): string {
return new Date(visibleDoseTimestampMs()).toISOString();
}
// =============================================================================
// Tests
// =============================================================================
@@ -259,9 +270,11 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
@@ -617,9 +630,10 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token for Daniel
@@ -628,15 +642,17 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark dose via share link
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Verify markedBy is "Daniel"
const result = await testClient.execute({
@@ -667,9 +683,10 @@ describe("Integration Tests", () => {
payload: {
name: "Vitamin D",
takenBy: ["Anna"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token
@@ -678,21 +695,24 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Anna", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark a dose
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Get shared schedule
const scheduleRes = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
const data = scheduleRes.json();
expect(data.takenBy).toBe("Anna");
@@ -781,7 +801,7 @@ describe("Integration Tests", () => {
payload: {
name: "Family Vitamins",
takenBy: ["Daniel", "Anna", "Max"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
@@ -799,8 +819,8 @@ describe("Integration Tests", () => {
});
// Both should succeed with different tokens
expect(danielShare.statusCode).toBe(200);
expect(annaShare.statusCode).toBe(200);
expect(danielShare.statusCode, danielShare.body).toBe(200);
expect(annaShare.statusCode, annaShare.body).toBe(200);
expect(danielShare.json().token).not.toBe(annaShare.json().token);
// Each share link should show correct person
+71
View File
@@ -248,6 +248,32 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "Missing planner data" });
});
it("should reject request when no planner date range can be resolved", async () => {
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing planner date range" });
});
it("should return error when no notification channels configured", async () => {
// User settings exist but email/shoutrrr disabled
await testClient.execute({
@@ -282,6 +308,51 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should accept startDate and endDate aliases for planner range", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
language: "en",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send email successfully when SMTP is configured", async () => {
// Set SMTP env vars
process.env.SMTP_HOST = "smtp.test.com";
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { redactTokenForLog } from "../utils/redaction.js";
describe("redactTokenForLog", () => {
it("returns a stable short hash reference without exposing the raw token", () => {
const rawToken = "share-token-secret-value";
const tokenRef = redactTokenForLog(rawToken);
expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/);
expect(tokenRef).toBe(redactTokenForLog(rawToken));
expect(tokenRef).not.toContain(rawToken);
});
it("normalizes empty tokens to a non-sensitive placeholder", () => {
expect(redactTokenForLog("")).toBe("missing");
expect(redactTokenForLog(" ")).toBe("missing");
expect(redactTokenForLog(null)).toBe("missing");
expect(redactTokenForLog(undefined)).toBe("missing");
});
});
+71 -3
View File
@@ -1,3 +1,4 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
@@ -6,10 +7,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
const env = {
AUTH_ENABLED: false,
@@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: env,
nodemailerSendMail: vi.fn(),
fetchMock: vi.fn(),
@@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
afterAll(async () => {
await app.close();
testClient.close();
if (existsSync(testDbPath)) {
unlinkSync(testDbPath);
}
});
beforeEach(async () => {
@@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => {
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
args: [1, `${medId}-0-1700000600000-alice`, 1700000600, 1],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
@@ -665,6 +673,66 @@ describe("Real route coverage: settings/export/report", () => {
expect(body[medId].dosesSkipped).toBe(1);
});
it("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => {
const medId = await seedMedication("Report Date Range Med");
const windowStart = "2026-01-10T00:00:00.000Z";
const windowEnd = "2026-01-20T00:00:00.000Z";
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000),
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, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)],
});
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, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd },
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body[medId]).toMatchObject({
dosesTaken: 1,
dosesSkipped: 1,
});
expect(body[medId].refills).toHaveLength(1);
expect(body[medId].refills[0]).toMatchObject({
packsAdded: 1,
usedPrescription: false,
});
});
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
const medId = await seedMedication("Export Med");
await testClient.execute({
+12 -4
View File
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
token?: string;
scheduleDays?: number;
expiresAt?: number | null;
allowJournalNotes?: boolean;
}
/**
* Create a test share token and return the token string
*/
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
const {
userId,
takenBy,
token = `test_token_${Date.now()}`,
scheduleDays = 30,
expiresAt = null,
allowJournalNotes = false,
} = options;
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
VALUES (?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt],
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
});
return token;
+17
View File
@@ -0,0 +1,17 @@
function pad(value: number, size = 2): string {
return String(value).padStart(size, "0");
}
export function toLocalDateTimeOffsetString(value: Date): string {
const offsetMinutes = -value.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = Math.floor(absoluteOffsetMinutes / 60);
const offsetRemainderMinutes = absoluteOffsetMinutes % 60;
return [
`${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`,
`T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`,
`${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`,
].join("");
}
+10
View File
@@ -0,0 +1,10 @@
import { createHash } from "node:crypto";
export function redactTokenForLog(token: string | null | undefined): string {
const normalizedToken = token?.trim();
if (!normalizedToken) {
return "missing";
}
return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`;
}
+2 -5
View File
@@ -7,11 +7,8 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
setupFiles: ["src/test/setup.ts"],
// Run tests sequentially to avoid DB conflicts
poolOptions: {
threads: {
singleThread: true,
},
},
fileParallelism: false,
maxWorkers: 1,
// Timeout for longer integration tests
testTimeout: 10000,
coverage: {
+14 -2
View File
@@ -9,9 +9,9 @@ Configure MedAssist with environment variables in `.env`. Start from `.env.examp
| `PUID` | `1000` | User ID for container file permissions |
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS in the Docker Compose quickstart; local Vite development commonly uses `http://localhost:5173` or `http://localhost:4173` |
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links. Strongly recommended for any deployment used from another device; do not point this to `localhost` or an internal Docker hostname. Local Vite development also allows this hostname automatically. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
@@ -22,6 +22,12 @@ API docs behavior:
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
`CORS_ORIGINS` note:
- The `.env.example` file is optimized for the Docker Compose quickstart, where the frontend runs on `http://localhost:4174`.
- Local frontend development uses the Vite dev server instead, so the backend schema defaults cover `http://localhost:5173` and `http://localhost:4173`.
- If you use a custom hostname or reverse proxy, include that origin in `CORS_ORIGINS`.
## Authentication
| Variable | Default | Description |
@@ -102,12 +108,18 @@ API reference:
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
These values are runtime defaults. User-specific settings can override reminder behavior after first save.
## Push Notifications
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
Notification action and share links should use `PUBLIC_APP_URL` as their reachable base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
If `PUBLIC_APP_URL` is missing in a remote deployment, reminder links can still be generated from local origins that are unreachable from phones or external browsers.
## Default User Settings
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
+22 -2
View File
@@ -19,8 +19,17 @@ If the frontend dev server runs behind a reverse proxy or on a remote host, set
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
## API Proxy Contract
- Frontend browser code should call `/api/*`, not hardcoded backend hostnames.
- Vite rewrites `/api/*` to the backend target configured by `BACKEND_URL` or the built-in default for the current environment.
- Default backend target:
- local dev outside Docker: `http://localhost:3000`
- dev stack inside Docker: `http://backend-dev:3000`
- If your backend runs on a different host or service name, set `BACKEND_URL` explicitly before starting Vite.
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1`
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1` plus the hostname from `PUBLIC_APP_URL` when configured
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
@@ -30,6 +39,17 @@ These development overrides are documented here intentionally and are not part o
```bash
npm run lint
npm run check
npm run build
cd backend && npm run test:run
cd frontend && npm run test:run
```
```
Recommended local maintenance preflight before opening or updating a PR:
```bash
npm run check
npm run build
```
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
+36 -1
View File
@@ -25,6 +25,23 @@ When an ntfy intake action succeeds, MedAssist publishes the confirmation as the
Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables.
Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach.
Good examples:
```text
https://med.example.com
https://medtest.example.com
```
Bad examples for notification actions:
```text
http://localhost:3000
http://backend-dev:3000
http://192.168.x.x:3000
```
Push-related default variables:
| Variable | Default | Description |
@@ -72,4 +89,22 @@ telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
## Troubleshooting
### ntfy `Take` / `Skip` fails with a connection timeout
If the ntfy client shows an error such as `failed to connect to ... port 443`, the failure usually happens before MedAssist can process the action token.
Check these points first:
1. `PUBLIC_APP_URL` points to your real public MedAssist URL, not to `localhost`, a Docker service name, or another internal-only address.
2. The same URL opens from the same phone and network outside the notification flow.
3. If the failure only happens on your home Wi-Fi, retry once on mobile data. That strongly helps distinguish an app issue from missing NAT loopback / hairpin routing on the local network.
### ntfy shows an old actionable entry after a successful action
MedAssist updates the notification state after a successful ntfy action and removes the stale actionable entry using the original ntfy message ID when available.
If an outdated actionable entry still remains visible, verify that the action actually reached MedAssist and that your ntfy server accepted both the confirmation publish and the follow-up delete of the original message.
+64 -13
View File
@@ -1,10 +1,35 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
import { type APIResponse, expect, type Page, test as setup } from "@playwright/test";
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
type StoredAuthCookie = {
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Strict" | "Lax" | "None";
};
type BrowserCookie = {
name: string;
value: string;
url: string;
expires?: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Strict" | "Lax" | "None";
};
type StoredAuthState = {
cookies?: StoredAuthCookie[];
};
/**
* Check if a JWT token is still valid (not expired) without making a
* network request. Returns `true` when the token has at least 2 minutes
@@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean {
}
}
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null {
const segments = setCookieHeader
.split(";")
.map((segment) => segment.trim())
@@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
return null;
}
const cookie: Cookie = {
const cookie: BrowserCookie = {
name: nameValue.slice(0, separatorIndex),
value: nameValue.slice(separatorIndex + 1),
url: baseURL,
@@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
return cookie;
}
async function syncResponseCookiesToBrowserContext(
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
baseURL: string,
response: APIResponse
): Promise<void> {
async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise<void> {
const cookies = response
.headersArray()
.filter((header) => header.name.toLowerCase() === "set-cookie")
.map((header) => toBrowserCookie(header.value, baseURL))
.filter((cookie): cookie is Cookie => cookie !== null);
.filter((cookie): cookie is BrowserCookie => cookie !== null);
if (cookies.length > 0) {
await page.context().addCookies(cookies);
@@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext(
setup("authenticate", async ({ page }) => {
setup.setTimeout(120000);
await applyVideoSafetyMode(page);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
// Create .auth directory if it doesn't exist
const authDir = path.dirname(authFile);
@@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => {
// ---- 1. Try to reuse an existing auth file (offline check only) ----
if (fs.existsSync(authFile)) {
try {
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState;
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token");
if (saved.cookies?.length) {
await page.context().addCookies(saved.cookies);
}
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
// Keep going and verify the session online. A JWT can be time-valid but
// still rejected by backend token rotation/restart.
const hasSavedSession = await page.request
.get(`${baseURL}/api/auth/me`)
.then((response) => response.ok())
.catch(() => false);
if (hasSavedSession) {
await page.context().storageState({ path: authFile });
return;
}
}
if (refreshCookie?.value) {
const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null);
if (refreshResponse?.ok()) {
await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse);
const refreshedSession = await page.request
.get(`${baseURL}/api/auth/me`)
.then((response) => response.ok())
.catch(() => false);
if (refreshedSession) {
await page.context().storageState({ path: authFile });
return;
}
}
}
} catch {
// Invalid file — fall through to regular login
@@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => {
// ---- 2. Fast path: already authenticated session ----
await page.goto("/");
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
let authEnabled = true;
let formLoginEnabled = true;
let oidcEnabled = false;
+19 -4
View File
@@ -289,6 +289,7 @@ export interface TestShareToken {
token: string;
takenBy: string;
scheduleDays: number;
allowJournalNotes?: boolean;
expiresAt: string;
}
@@ -303,7 +304,7 @@ export async function createMedicationViaAPI(data: {
takenBy?: string[];
notes?: string;
expiryDate?: string;
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
packCount?: number;
blistersPerPack?: number;
@@ -323,7 +324,12 @@ export async function createMedicationViaAPI(data: {
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
const packageType = data.packageType ?? "blister";
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
const isAmountBased =
packageType === "bottle" ||
packageType === "tube" ||
packageType === "liquid_container" ||
packageType === "inhaler" ||
packageType === "injection";
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
if (packageType === "tube") {
defaultMedicationForm = "topical";
@@ -455,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
* Create a share token via the backend API.
* Requires a medication with takenBy to exist first.
*/
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
export async function createShareTokenViaAPI(
takenBy: string,
scheduleDays = 30,
options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {}
): Promise<TestShareToken> {
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 5; attempt++) {
@@ -465,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
"Content-Type": "application/json",
...(token ? { Cookie: `access_token=${token}` } : {}),
},
body: JSON.stringify({ takenBy, scheduleDays }),
body: JSON.stringify({
takenBy,
scheduleDays,
expiryDays: options.expiryDays ?? null,
allowJournalNotes: options.allowJournalNotes ?? false,
}),
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
+48 -11
View File
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
opts: {
name: string;
genericName?: string;
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
packs?: string;
blistersPerPack?: string;
pillsPerBlister?: string;
@@ -50,12 +50,17 @@ async function fillAndSaveMedication(
}
const packageTypeSelect = form.locator("select.package-type-select");
if (opts.packageType === "bottle") {
await packageTypeSelect.selectOption("bottle");
if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") {
await packageTypeSelect.selectOption(opts.packageType ?? "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);
await form
.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i)
.fill(opts.totalCapacity);
if (opts.currentPills)
await form
.getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i)
.fill(opts.currentPills);
} else if (opts.packageType === "tube") {
await packageTypeSelect.selectOption("tube");
await page.getByRole("tab", { name: /Package/i }).click();
@@ -95,12 +100,12 @@ async function fillAndSaveMedication(
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
}
const row = form.locator(".blister-row").nth(i);
await row
.getByLabel(
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
)
.fill(intakes[i].usage);
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
const usageField = row.getByRole("textbox", {
name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i,
});
const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill(intakes[i].usage);
await everyField.fill(intakes[i].every);
}
await page.waitForLoadState("networkidle");
@@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => {
});
});
test("should create an inhaler medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Rescue Inhaler",
packageType: "inhaler",
totalCapacity: "200",
currentPills: "120",
intakes: [{ usage: "2", every: "1" }],
});
const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" });
await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i);
await expect(medRow.locator(".med-total")).toContainText("120 / 200");
});
test("should create an injection medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Weekly Injection",
packageType: "injection",
totalCapacity: "12",
currentPills: "4",
intakes: [{ usage: "1", every: "7" }],
});
const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" });
await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i);
await expect(medRow.locator(".med-total")).toContainText("4 / 12");
});
test("should create medication with multiple intake schedules", async ({ page }) => {
await navigateTo(page, "/medications");
+137 -14
View File
@@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
});
}
async function openMedicationDetailFromDashboard(page: Page, medName: string) {
const overviewTable = page.locator(".dashboard-overview-section .table").first();
for (let attempt = 0; attempt < 3; attempt++) {
try {
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const medRow = overviewTable.locator(".table-row").filter({ hasText: medName });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.click();
const modal = page.locator(".modal-content.med-detail-modal");
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 });
return modal;
} catch {
if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`);
await page.reload();
await page.waitForLoadState("networkidle");
}
}
throw new Error(`Failed to open dashboard medication detail for ${medName}`);
}
/** Helper: save edit and verify success */
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
const form = page.locator("form.form-grid:visible").first();
@@ -310,24 +332,107 @@ test.describe("Medication Editing", () => {
// Find the remind checkbox in the intake row
const intakeRow = page.locator(".blister-row").first();
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
const remindToggle = intakeRow.locator(".toggle-switch");
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
if (await remindCheckbox.isVisible().catch(() => false)) {
// Should be unchecked initially
await expect(remindCheckbox).not.toBeChecked();
await remindToggle.click();
await expect(remindCheckbox).toBeChecked();
await saveEditAndVerify(page, "Reminder Toggle Med");
// Verify reminder was saved
await clickEditMed(page, "Reminder Toggle Med");
const savedCheckbox = page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]');
await expect(savedCheckbox).toBeChecked();
});
for (const scenario of [
{
name: "Inhaler Reminder Refill Med",
packageType: "inhaler" as const,
totalCapacity: 200,
currentStock: 120,
refillAmount: 30,
expectedStock: 150,
unitLabel: /puffs?|common\.puffs?/i,
},
{
name: "Injection Reminder Refill Med",
packageType: "injection" as const,
totalCapacity: 12,
currentStock: 4,
refillAmount: 3,
expectedStock: 7,
unitLabel: /injections?|common\.injections?/i,
},
]) {
test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: scenario.name,
packageType: scenario.packageType,
totalPills: scenario.totalCapacity,
looseTablets: scenario.currentStock,
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, scenario.name);
await page.getByRole("tab", { name: /Schedule/i }).click();
const intakeRow = page.locator(".blister-row").first();
const remindToggle = intakeRow.locator(".toggle-switch");
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
await expect(remindCheckbox).not.toBeChecked();
// Enable it
await remindCheckbox.check();
await remindToggle.click();
await expect(remindCheckbox).toBeChecked();
await saveEditAndVerify(page, "Reminder Toggle Med");
await saveEditAndVerify(page, scenario.name);
// Verify reminder was saved
await clickEditMed(page, "Reminder Toggle Med");
const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]');
await expect(savedCheckbox).toBeChecked();
}
});
await clickEditMed(page, scenario.name);
await page.getByRole("tab", { name: /Schedule/i }).click();
await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked();
await navigateTo(page, "/dashboard");
const modal = await openMedicationDetailFromDashboard(page, scenario.name);
await modal.getByRole("button", { name: /Refill|refill\.button/i }).click();
const refillModal = page.locator(".modal-content.refill-modal");
await expect(refillModal).toBeVisible({ timeout: 5000 });
const refillInput = refillModal.locator('input[type="number"]').first();
await refillInput.fill(String(scenario.refillAmount));
await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`);
await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel);
await refillModal.locator(".modal-footer .success").click();
await expect(refillModal).not.toBeVisible({ timeout: 10000 });
const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({
hasText: /Refill History|refill\.history/i,
});
await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 });
await refillHistoryHeader.click();
const refillAmount = modal.locator(".refill-history-item .refill-amount").first();
await expect(refillAmount).toContainText(`+${scenario.refillAmount}`);
await expect(refillAmount).toContainText(scenario.unitLabel);
await page.locator("button.modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await navigateTo(page, "/medications");
const medRow = page.locator(".med-row").filter({ hasText: scenario.name });
await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`);
});
}
test("should change package type across all supported profiles", async ({ page }) => {
createdMeds.push(
@@ -369,12 +474,30 @@ test.describe("Medication Editing", () => {
await packageSelect.selectOption("liquid_container");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to inhaler
await packageSelect.selectOption("inhaler");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
).toBeVisible();
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to injection and persist this final state
await packageSelect.selectOption("injection");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
).toBeVisible();
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
await saveEditAndVerify(page, "PackType Change Med");
// Verify final package type persisted
await clickEditMed(page, "PackType Change Med");
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
await expect(page.locator("select.package-type-select")).toHaveValue("injection");
});
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
+94
View File
@@ -0,0 +1,94 @@
import {
authFile,
createMedicationViaAPI,
createShareTokenViaAPI,
deleteAllMedicationsViaAPI,
expect,
navigateTo,
test,
} from "./fixtures";
test.describe("Mobile modal browser back", () => {
test.use({
storageState: authFile,
viewport: { width: 412, height: 915 },
isMobile: true,
hasTouch: true,
});
test("closes owner-side modals with browser back on a Pixel-width viewport", async ({ page }) => {
await navigateTo(page, "/dashboard");
const journalHistoryButton = page.locator(".journal-history-button").first();
await expect(journalHistoryButton).toBeVisible({ timeout: 10000 });
await journalHistoryButton.click();
const journalHistoryModal = page.locator(".journal-history-modal");
await expect(journalHistoryModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalHistoryModal).toBeHidden({ timeout: 10000 });
await navigateTo(page, "/settings");
const exportButton = page
.locator("button.secondary")
.filter({ hasText: /Export|Exportieren/i })
.first();
await expect(exportButton).toBeVisible({ timeout: 10000 });
await exportButton.click();
const exportModal = page.locator(".modal-content").filter({ hasText: /Export Options|Export-Optionen/i });
await expect(exportModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(exportModal).toBeHidden({ timeout: 10000 });
});
test("closes the shared intake journal modal with browser back on mobile", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Mobile Journal ${uniqueSuffix}`;
const medicationName = `Mobile Shared Journal ${uniqueSuffix}`;
const start = new Date();
start.setHours(8, 0, 0, 0);
const pad = (value: number) => value.toString().padStart(2, "0");
const startTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`;
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: startTime, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const noteButton = page.locator(".dose-item").first().locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const journalModal = page.locator(".journal-modal");
await expect(journalModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalModal).toBeHidden({ timeout: 10000 });
await expect(page.locator(".shared-schedule-container")).toBeVisible();
});
});
+56 -1
View File
@@ -18,7 +18,7 @@ import {
*/
test.describe("Share Schedule", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
test.describe.configure({ mode: "serial", timeout: 90000 });
const MED_ALICE = "ShareTest AliceMed";
const MED_BOB = "ShareTest BobMed";
@@ -300,4 +300,59 @@ test.describe("Share Schedule", () => {
await page.locator("button.modal-close").click();
});
test("should let a shared recipient add and reopen a journal note", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Journal E2E ${uniqueSuffix}`;
const medicationName = `Share Journal E2E ${uniqueSuffix}`;
const journalNote = `Shared E2E note ${uniqueSuffix}`;
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled();
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const updatedDoseItem = page.locator(".dose-item").first();
const noteButton = updatedDoseItem.locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const noteInput = page.locator("#journal-note-input");
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue("");
await noteInput.fill(journalNote);
await page.locator(".journal-modal-footer button.primary").click();
await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 });
await noteButton.click();
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 });
});
});
+175 -182
View File
@@ -1,37 +1,37 @@
{
"name": "medassist-ng-frontend",
"version": "1.23.0",
"version": "1.25.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.23.0",
"version": "1.25.1",
"dependencies": {
"i18next": "^26.1.0",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.2",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.6",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.12",
"vite": "^8.0.13",
"vitest": "^4.1.0"
}
},
@@ -594,9 +594,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -604,13 +604,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -620,9 +620,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -637,9 +637,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -654,9 +654,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -671,9 +671,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -688,9 +688,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -705,9 +705,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -722,9 +722,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -739,9 +739,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -756,9 +756,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -773,9 +773,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -790,9 +790,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -807,9 +807,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -824,9 +824,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -843,9 +843,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -860,9 +860,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -877,9 +877,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -1032,13 +1032,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": {
@@ -1085,13 +1085,13 @@
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.7"
"@rolldown/pluginutils": "^1.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -1111,14 +1111,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -1132,8 +1132,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.6",
"vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -1142,16 +1142,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -1160,13 +1160,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -1187,9 +1187,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1200,13 +1200,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -1214,14 +1214,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -1230,9 +1230,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1240,13 +1240,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -1548,9 +1548,9 @@
}
},
"node_modules/i18next": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.1.0.tgz",
"integrity": "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ==",
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
"funding": [
{
"type": "individual",
@@ -1961,9 +1961,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2106,13 +2106,13 @@
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -2125,9 +2125,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2214,9 +2214,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.7",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.7.tgz",
"integrity": "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg==",
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -2224,7 +2224,7 @@
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.10",
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
@@ -2249,9 +2249,9 @@
"peer": true
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -2271,12 +2271,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
"license": "MIT",
"dependencies": {
"react-router": "7.15.0"
"react-router": "7.15.1"
},
"engines": {
"node": ">=20.0.0"
@@ -2311,14 +2311,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2327,30 +2327,23 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -2576,9 +2569,9 @@
}
},
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
@@ -2592,16 +2585,16 @@
}
},
"node_modules/vite": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.0",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -2685,19 +2678,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -2725,12 +2718,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.25.1",
"version": "1.26.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,30 +27,30 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"i18next": "^26.1.0",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-router-dom": "^7.15.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@playwright/test": "^1.59.1",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.6.2",
"@types/node": "^25.8.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.6",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.12",
"vite": "^8.0.13",
"vitest": "^4.1.0"
}
}
+125 -88
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
AboutModal,
@@ -11,9 +12,19 @@ import {
} from "./components";
import { AppHeader } from "./components/AppHeader";
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { AppProvider, FeedbackProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
import { useScrollLock } from "./hooks/useScrollLock";
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((module) => ({ default: module.DashboardPage })));
const MedicationsPage = lazy(() =>
import("./pages/MedicationsPage").then((module) => ({ default: module.MedicationsPage }))
);
const PlannerPage = lazy(() => import("./pages/PlannerPage").then((module) => ({ default: module.PlannerPage })));
const SchedulePage = lazy(() => import("./pages/SchedulePage").then((module) => ({ default: module.SchedulePage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
const SharedOverviewPage = lazy(() =>
import("./pages/SharedOverviewPage").then((module) => ({ default: module.SharedOverviewPage }))
);
// Vite injects this at build time from package.json
declare const __APP_VERSION__: string;
@@ -21,19 +32,78 @@ export const FRONTEND_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_V
const GITHUB_REPO = "DanielVolz/medassist-ng";
export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
function RouteLoadingFallback() {
const { t } = useTranslation();
return <div style={{ padding: "1rem", textAlign: "center" }}>{t("common.loading")}</div>;
}
function AuthStatusCard({ theme, children }: { theme: "light" | "dark"; children: React.ReactNode }) {
return (
<div className="auth-container" data-theme={theme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
{children}
</div>
</div>
);
}
// =============================================================================
// Main App Wrapper with Auth
// =============================================================================
export default function App() {
// Close tooltips on scroll/touch (for mobile). Keep this in the public
// wrapper too so shared links get the same tooltip behavior as the app.
useEffect(() => {
const closeAllTooltips = () => {
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;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
closeAllTooltips();
tooltipTrigger.classList.add("tooltip-active");
if (window.innerWidth <= 640) {
const rect = tooltipTrigger.getBoundingClientRect();
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
}
} else {
closeAllTooltips();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
return (
<AuthProvider>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
<FeedbackProvider>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
{/* Public share route - accessible without auth */}
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
<Route path="/share/:token" element={<SharedSchedule />} />
{/* All other routes go through AppRouter */}
<Route path="*" element={<AppRouter />} />
</Routes>
</Suspense>
</FeedbackProvider>
</AuthProvider>
);
}
@@ -54,52 +124,42 @@ function getInitialAuthTheme(): "light" | "dark" {
}
function AppRouter() {
const { t } = useTranslation();
const { user, authState, loading, authError } = useAuth();
const authTheme = getInitialAuthTheme();
// Show loading while checking auth state
if (loading) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.loading")}</p>
</AuthStatusCard>
);
}
// Show error if we couldn't connect to the server
if (authError) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>Connection Error</strong>
<br />
{authError}
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
Please check if the server is running and try again.
</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
Retry
</button>
<AuthStatusCard theme={authTheme}>
<div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>{t("auth.connectionErrorTitle")}</strong>
<br />
{authError}
</div>
</div>
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>{t("auth.connectionErrorHelp")}</p>
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
{t("common.retry")}
</button>
</AuthStatusCard>
);
}
// If auth state is null (shouldn't happen after loading, but be safe)
if (!authState) {
return (
<div className="auth-container" data-theme={authTheme}>
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p>
</div>
</div>
<AuthStatusCard theme={authTheme}>
<p>{t("common.initializing")}</p>
</AuthStatusCard>
);
}
@@ -193,12 +253,20 @@ function AppContent() {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
@@ -272,47 +340,6 @@ function AppContent() {
setShowRefillModal,
]);
// Close tooltips on scroll/touch (for mobile)
useEffect(() => {
const closeAllTooltips = () => {
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;
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
if (tooltipTrigger) {
// Close other tooltips first
closeAllTooltips();
// Toggle this one
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();
}
};
const handleTouchMove = () => {
closeAllTooltips();
};
document.addEventListener("click", handleTooltipClick, { capture: true });
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("scroll", handleTouchMove, { passive: true });
return () => {
document.removeEventListener("click", handleTooltipClick, { capture: true });
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("scroll", handleTouchMove);
};
}, []);
// Global Escape handling in priority order.
// This keeps behavior consistent even when child modals are mocked in tests.
useEffect(() => {
@@ -505,20 +532,22 @@ function AppContent() {
{/* About Modal */}
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/planner" element={<PlannerPage />} />
<Route path="/planner" element={<PlannerPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/schedule" element={<SchedulePage />} />
{/* Catch-all: redirect unknown routes to dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
<Route path="/schedule" element={<SchedulePage />} />
{/* Catch-all: redirect unknown routes to dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
{/* Medication Detail Modal */}
<MedDetailModal
@@ -581,13 +610,21 @@ function AppContent() {
onShareSelectedPersonChange={setShareSelectedPerson}
shareSelectedDays={shareSelectedDays}
onShareSelectedDaysChange={setShareSelectedDays}
shareSelectedExpiryDays={shareSelectedExpiryDays}
onShareSelectedExpiryDaysChange={setShareSelectedExpiryDays}
shareAllowJournalNotes={shareAllowJournalNotes}
onShareAllowJournalNotesChange={setShareAllowJournalNotes}
shareGenerating={shareGenerating}
shareLink={shareLink}
onShareLinkChange={setShareLink}
shareCopied={shareCopied}
onShareCopiedChange={setShareCopied}
activeShareLinks={activeShareLinks}
activeSharesLoading={activeSharesLoading}
revokingShareToken={revokingShareToken}
onClose={closeShareDialog}
onGenerateShareLink={generateShareLink}
onRevokeShareLink={revokeShareLink}
onCopyShareLink={copyShareLink}
/>
+32 -2
View File
@@ -2,6 +2,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useModalHistory } from "../hooks/useModalHistory";
import { withCorrelation } from "../utils/correlation";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import { log } from "../utils/logger";
@@ -32,6 +33,7 @@ interface AuthContextType {
authState: AuthState | null;
loading: boolean;
authError: string | null;
sessionExpired: boolean;
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
@@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// Track if initial fetch has been done to prevent duplicate calls
const initialFetchDone = useRef(false);
@@ -113,6 +116,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// If auth is enabled and we might be logged in, check session
if (state.authEnabled) {
await refreshUser();
} else {
setSessionExpired(false);
}
setLoading(false);
} catch (err) {
@@ -138,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (res.ok) {
const userData = await res.json();
setUser(userData);
setSessionExpired(false);
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
} else if (res.status === 401) {
// Access token expired - try to refresh it
@@ -150,6 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (retryRes.ok) {
const userData = await retryRes.json();
setUser(userData);
setSessionExpired(false);
log.info("[Auth] Session restored after token refresh", {
userId: userData.id,
correlationId: retry.correlationId,
@@ -159,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
setUser(null);
setSessionExpired(true);
} else {
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
setUser(null);
@@ -215,6 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json();
setUser(data.user);
setSessionExpired(false);
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
}
@@ -233,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Auto-login after registration
await login(username, password);
setSessionExpired(false);
// Refresh auth state (registration might disable further registrations)
await fetchAuthState();
@@ -249,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
await fetch("/api/auth/logout", init);
setUser(null);
setSessionExpired(false);
log.info("[Auth] Logout completed", { correlationId });
}
@@ -341,9 +352,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (refreshed) {
// Retry the original request with new token
res = await fetch(input, options);
if (res.ok) {
setSessionExpired(false);
}
} else {
// Refresh failed - user needs to login again
setUser(null);
setSessionExpired(true);
}
}
@@ -359,6 +374,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authState,
loading,
authError,
sessionExpired,
login,
register,
logout,
@@ -386,7 +402,7 @@ export function LoginForm({
onSwitchToRegister?: () => void;
}) {
const { t } = useTranslation();
const { login, authState } = useAuth();
const { login, authState, sessionExpired } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
@@ -440,6 +456,13 @@ export function LoginForm({
{/* Local login form - only show if form login is enabled */}
{authState?.formLoginEnabled && (
<form onSubmit={handleSubmit} className="auth-form">
{sessionExpired && (
<div className="auth-error">
<strong>{t("auth.sessionExpiredTitle")}</strong>
<br />
{t("auth.sessionExpiredHelp")}
</div>
)}
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const closeDeleteConfirm = useCallback(() => {
if (!deleteLoading) {
setShowDeleteConfirm(false);
}
}, [deleteLoading]);
useEscapeKey(!!onClose, onClose ?? (() => {}));
useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm);
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -842,7 +872,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
onCancel={closeDeleteConfirm}
isLoading={deleteLoading}
confirmVariant="danger"
/>
@@ -0,0 +1,161 @@
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ImportPreview } from "../context/AppContext";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
interface ImportReviewModalProps {
isOpen: boolean;
importPreview: ImportPreview | null;
formattedExportedAt: string;
importing: boolean;
exporting: boolean;
onClose: () => void;
onBackup: () => void;
onConfirm: () => void;
}
export function ImportReviewModal({
isOpen,
importPreview,
formattedExportedAt,
importing,
exporting,
onClose,
onBackup,
onConfirm,
}: ImportReviewModalProps) {
const { t } = useTranslation();
const titleId = "import-review-modal-title";
const hasExistingData = importPreview?.warnings.replacesExistingData ?? false;
const hasWarnings = Boolean(
importPreview?.warnings.replacesExistingData ||
importPreview?.warnings.regeneratesShareLinks ||
importPreview?.warnings.containsImages ||
importPreview?.warnings.containsSensitiveData
);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen || !importPreview) {
return null;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content confirm-modal import-review-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
<X size={20} aria-hidden="true" />
</button>
<h2 id={titleId}>{t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}</h2>
<div className="import-review-body">
<p>{t(hasExistingData ? "exportImport.reviewDescription" : "exportImport.reviewDescriptionEmpty")}</p>
<div className="import-review-summary">
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.incomingData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.incoming.medications,
doses: importPreview.incoming.doseHistory,
refills: importPreview.incoming.refillHistory,
shares: importPreview.incoming.shareLinks,
})}
</span>
</div>
<div className="import-review-meta">
<span>{t("exportImport.formatVersion", { version: importPreview.version })}</span>
<span>{t("exportImport.exportedAt", { date: formattedExportedAt })}</span>
{importPreview.incoming.hasSettings && <span>{t("exportImport.settingsIncluded")}</span>}
{importPreview.incoming.journalEntries > 0 && (
<span>{t("exportImport.journalEntries", { count: importPreview.incoming.journalEntries })}</span>
)}
{importPreview.incoming.imageCount > 0 && (
<span>{t("exportImport.imageCount", { count: importPreview.incoming.imageCount })}</span>
)}
</div>
</div>
<div className="action-card">
<div className="action-card-content">
<span className="action-card-title">{t("exportImport.currentData")}</span>
<span className="action-card-desc">
{t("exportImport.summaryCounts", {
medications: importPreview.current.medications,
doses: importPreview.current.doseHistory,
refills: importPreview.current.refillHistory,
shares: importPreview.current.shareLinks,
})}
</span>
</div>
{importPreview.current.hasSettings && (
<span className="import-review-meta">{t("exportImport.settingsConfigured")}</span>
)}
</div>
</div>
{hasWarnings && (
<div className="import-review-warnings">
<strong>{t("exportImport.warningListTitle")}</strong>
<ul>
{importPreview.warnings.replacesExistingData && <li>{t("exportImport.warningReplaceData")}</li>}
{importPreview.warnings.regeneratesShareLinks && <li>{t("exportImport.warningShareLinks")}</li>}
{importPreview.warnings.containsImages && <li>{t("exportImport.warningImages")}</li>}
{importPreview.warnings.containsSensitiveData && <li>{t("exportImport.warningSensitive")}</li>}
</ul>
</div>
)}
{hasExistingData ? (
<p className="warning-text">{t("exportImport.confirmImportWarning")}</p>
) : (
<p className="hint-text">{t("exportImport.confirmImportEmptyMessage")}</p>
)}
<p className="hint-text">{t("exportImport.backupHint")}</p>
</div>
<div className="modal-footer import-review-footer">
<button type="button" className="ghost" onClick={onClose} disabled={importing || exporting}>
{t("exportImport.cancelButton")}
</button>
<div className="import-review-actions">
{hasExistingData && (
<button type="button" className="secondary" onClick={onBackup} disabled={exporting || importing}>
{exporting ? t("exportImport.exporting") : t("exportImport.backupFirst")}
</button>
)}
<button
type="button"
className={hasExistingData ? "danger" : "primary"}
onClick={onConfirm}
disabled={importing}
>
{importing
? t("exportImport.importing")
: t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -298,7 +298,12 @@ export function MedicationEnrichmentSection({
}
const animationFrameId = window.requestAnimationFrame(() => {
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
const expandedResultElement = resultRefs.current.get(expandedResultCode);
if (typeof expandedResultElement?.scrollIntoView !== "function") {
return;
}
expandedResultElement.scrollIntoView({
block: "nearest",
inline: "nearest",
behavior: "smooth",
+131 -25
View File
@@ -11,8 +11,11 @@ import {
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { formatDate, formatDateTime, toInputValue } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { mergePersonTags, personTagsMatch } from "../utils/person-tags";
import { useAuth } from "./Auth";
import { DateTimeInput } from "./DateTimeInput";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -41,31 +44,53 @@ type ReportData = Record<
}
>;
type ReportDateRange = {
startDate: string;
endDate: string;
};
type ReportPreview = {
format: "txt" | "md";
content: string;
};
function getDefaultDateRange(): ReportDateRange {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 30);
return {
startDate: toInputValue(startDate),
endDate: toInputValue(endDate),
};
}
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
const { t } = useTranslation();
const { authFetch } = useAuth();
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());
const [dateRange, setDateRange] = useState<ReportDateRange>(() => getDefaultDateRange());
const [preview, setPreview] = useState<ReportPreview | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
// Collect all unique "taken by" people across all medications
const allPeople = useMemo(() => {
const people = new Set<string>();
for (const med of medications) {
if (med.takenBy) {
for (const p of med.takenBy) people.add(p);
}
}
return Array.from(people).sort();
return mergePersonTags(medications.flatMap((medication) => medication.takenBy || []));
}, [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)));
return medications.filter((medication) =>
medication.takenBy?.some((person) =>
Array.from(takenByFilter).some((filterValue) => personTagsMatch(person, filterValue))
)
);
}, [medications, takenByFilter]);
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
@@ -97,9 +122,22 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
setTakenByFilter(new Set());
setFormat("pdf");
setGenerating(false);
setDateRange(getDefaultDateRange());
setPreview(null);
setErrorMessage(null);
}
}, [isOpen]);
// biome-ignore lint/correctness/useExhaustiveDependencies: preview should reset when any report input changes while the modal is open
useEffect(() => {
if (!isOpen) {
return;
}
setPreview(null);
setErrorMessage(null);
}, [isOpen, selectedIds, takenByFilter, format, dateRange.startDate, dateRange.endDate]);
const toggleMed = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -118,37 +156,59 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
}, []);
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
let generateButtonLabel = t("report.generate");
if (generating) {
generateButtonLabel = t("report.generating");
} else if (preview) {
generateButtonLabel = t("report.regenerate");
}
async function handleGenerate() {
if (selectedIds.size === 0) return;
const startDate = new Date(dateRange.startDate);
const endDate = new Date(dateRange.endDate);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
setErrorMessage(t("report.invalidDateRange"));
return;
}
setGenerating(true);
setErrorMessage(null);
try {
const resolvedDateRange = {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
};
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
// Fetch report data from backend
const res = await fetch("/api/medications/report-data", {
const res = await authFetch("/api/medications/report-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
medicationIds: Array.from(selectedIds),
startDate: resolvedDateRange.startDate,
endDate: resolvedDateRange.endDate,
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
}),
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);
const imageMap = await fetchMedImages(selectedMeds, authFetch);
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
setPreview(null);
setErrorMessage(null);
onClose();
} else {
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
downloadFile(content, format);
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
setPreview({ format, content });
}
onClose();
} catch {
// Stay open on error so user can retry
setErrorMessage(t("report.error"));
} finally {
setGenerating(false);
}
@@ -177,6 +237,28 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
<h2 className="report-modal-title">{t("report.title")}</h2>
<p className="report-modal-desc">{t("report.description")}</p>
<div className="report-range">
<h4>{t("report.dateRange")}</h4>
<div className="report-range-grid">
<div className="report-range-field">
<span>{t("report.from")}</span>
<DateTimeInput
step="60"
value={dateRange.startDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
/>
</div>
<div className="report-range-field">
<span>{t("report.until")}</span>
<DateTimeInput
step="60"
value={dateRange.endDate}
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
/>
</div>
</div>
</div>
{/* Person filter */}
{allPeople.length > 1 && (
<div className="report-person-filter">
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
</div>
</div>
{errorMessage && <p className="report-error">{errorMessage}</p>}
{preview && (
<div className="report-preview">
<div className="report-preview-header">
<h4>{t("report.preview")}</h4>
<button
type="button"
className="ghost small"
onClick={() => downloadFile(preview.content, preview.format)}
>
{t("report.download")}
</button>
</div>
<p className="report-preview-desc">{t("report.previewDescription")}</p>
<pre className="report-preview-content">{preview.content}</pre>
</div>
)}
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
@@ -290,7 +391,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
onClick={handleGenerate}
disabled={selectedIds.size === 0 || generating}
>
{generating ? t("report.generating") : t("report.generate")}
{generateButtonLabel}
</button>
</div>
</div>
@@ -348,7 +449,8 @@ function generateTextReport(
reportData: ReportData,
fmt: "txt" | "md",
t: TFn,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const lines: string[] = [];
const sep = fmt === "md" ? "---" : "═".repeat(60);
@@ -360,6 +462,7 @@ function generateTextReport(
lines.push(h1(t("report.docTitle")));
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
lines.push(`${t("report.docRange")}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}`);
lines.push("");
for (const med of meds) {
@@ -483,13 +586,13 @@ function downloadFile(content: string, format: "txt" | "md") {
type ImageMap = Record<number, string>;
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
async function fetchMedImages(meds: Medication[], authFetch: typeof fetch): 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" });
const res = await authFetch(`/api/images/${m.imageUrl}`);
if (!res.ok) return;
const blob = await res.blob();
const dataUrl = await new Promise<string>((resolve) => {
@@ -511,12 +614,13 @@ function openPrintView(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
) {
const w = window.open("", "_blank");
if (!w) return;
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter, dateRange);
w.document.write(html);
w.document.close();
w.onload = () => setTimeout(() => w.print(), 300);
@@ -531,7 +635,8 @@ function buildPrintHtml(
reportData: ReportData,
t: TFn,
imageMap: ImageMap,
personFilter: string[] | null
personFilter: string[] | null,
dateRange: { startDate: string; endDate: string }
): string {
const sections: string[] = [];
@@ -721,6 +826,7 @@ function buildPrintHtml(
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
<p class="subtitle">${escHtml(t("report.docRange"))}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}</p>
${sections.join("\n")}
</body>
</html>`;
+152
View File
@@ -4,7 +4,11 @@
*/
import { Check, Copy, Link2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useModalHistory } from "../hooks";
import type { ActiveShareLink } from "../hooks/useShare";
import { ConfirmModal } from "./ConfirmModal";
export interface ShareDialogProps {
show: boolean;
@@ -13,13 +17,21 @@ export interface ShareDialogProps {
onShareSelectedPersonChange: (person: string) => void;
shareSelectedDays: number;
onShareSelectedDaysChange: (days: number) => void;
shareSelectedExpiryDays: number | null;
onShareSelectedExpiryDaysChange: (days: number | null) => void;
shareAllowJournalNotes: boolean;
onShareAllowJournalNotesChange: (enabled: boolean) => void;
shareGenerating: boolean;
shareLink: string | null;
onShareLinkChange: (link: string | null) => void;
shareCopied: boolean;
onShareCopiedChange: (copied: boolean) => void;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
onClose: () => void;
onGenerateShareLink: () => Promise<void>;
onRevokeShareLink: (token: string) => Promise<boolean>;
onCopyShareLink: () => void;
}
@@ -30,24 +42,116 @@ export function ShareDialog({
onShareSelectedPersonChange,
shareSelectedDays,
onShareSelectedDaysChange,
shareSelectedExpiryDays,
onShareSelectedExpiryDaysChange,
shareAllowJournalNotes,
onShareAllowJournalNotesChange,
shareGenerating,
shareLink,
onShareLinkChange,
shareCopied,
onShareCopiedChange,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
onClose,
onGenerateShareLink,
onRevokeShareLink,
onCopyShareLink,
}: ShareDialogProps) {
const { t } = useTranslation();
const [manageLinksOpen, setManageLinksOpen] = useState(false);
const [shareToRevoke, setShareToRevoke] = useState<ActiveShareLink | null>(null);
const closeLabel = t("common.close");
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
const closeRevokeConfirm = useCallback(() => {
if (shareToRevoke && revokingShareToken !== shareToRevoke.token) {
setShareToRevoke(null);
}
}, [revokingShareToken, shareToRevoke]);
useModalHistory(show && Boolean(shareToRevoke), "share-revoke", closeRevokeConfirm);
useEffect(() => {
if (!show) {
setShareToRevoke(null);
}
}, [show]);
// ESC is handled by the global handler in App.tsx to avoid double history.back()
if (!show) return null;
const renderActiveShares = () => {
if (activeSharesLoading) {
return <p>{t("share.loadingActiveLinks")}</p>;
}
if (activeShareLinks.length === 0) {
return <p>{t("share.noActiveLinks")}</p>;
}
return (
<ul className="share-active-list">
{activeShareLinks.map((share) => {
const personLabel = getPersonLabel(share.takenBy);
const createdAtLabel = new Date(share.createdAt).toLocaleDateString();
const expiresAtLabel = share.expiresAt ? new Date(share.expiresAt).toLocaleDateString() : null;
return (
<li key={share.token} className="share-active-item">
<div className="share-active-copy">
<a href={`${window.location.origin}${share.shareUrl}`} className="share-link-inline">
{personLabel}
</a>
<span className="hint-text">
{expiresAtLabel
? t("share.activeLinkMetaWithExpiry", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
expiresAt: expiresAtLabel,
})
: t("share.activeLinkMeta", {
person: personLabel,
days: share.scheduleDays,
createdAt: createdAtLabel,
})}
{share.allowJournalNotes ? ` · ${t("share.journalNotesEnabled")}` : ""}
</span>
</div>
<button
type="button"
className="ghost"
disabled={revokingShareToken === share.token}
onClick={() => setShareToRevoke(share)}
>
{revokingShareToken === share.token ? t("share.revoking") : t("share.revoke")}
</button>
</li>
);
})}
</ul>
);
};
const renderManageLinks = () => (
<div className="share-dialog-manage">
<button
type="button"
className="share-dialog-manage-summary"
onClick={() => setManageLinksOpen((current) => !current)}
aria-expanded={manageLinksOpen}
>
<span>{t("share.manageLinksSummary", { count: activeShareLinks.length })}</span>
<span className="share-dialog-manage-count">
{manageLinksOpen ? t("common.hide") : activeShareLinks.length}
</span>
</button>
{manageLinksOpen ? <div className="share-dialog-manage-content">{renderActiveShares()}</div> : null}
</div>
);
return (
<div
className="modal-overlay"
@@ -85,6 +189,7 @@ export function ShareDialog({
return (
<div className="share-dialog-empty">
<p>{t("share.noPeople")}</p>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -124,6 +229,7 @@ export function ShareDialog({
</button>
<button onClick={onClose}>{t("common.close")}</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
}
@@ -159,6 +265,33 @@ export function ShareDialog({
</select>
</div>
<div className="form-group">
<label htmlFor="share-expiry-select">{t("share.selectExpiry")}</label>
<select
id="share-expiry-select"
className="select-field"
value={shareSelectedExpiryDays == null ? "never" : String(shareSelectedExpiryDays)}
onChange={(e) =>
onShareSelectedExpiryDaysChange(e.target.value === "never" ? null : Number(e.target.value))
}
>
<option value="never">{t("share.expiryNever")}</option>
<option value="7">{t("share.expiry7Days")}</option>
<option value="30">{t("share.expiry30Days")}</option>
<option value="90">{t("share.expiry90Days")}</option>
</select>
</div>
<label className="inline-checkbox" htmlFor="share-journal-notes-toggle">
<input
id="share-journal-notes-toggle"
type="checkbox"
checked={shareAllowJournalNotes}
onChange={(event) => onShareAllowJournalNotesChange(event.target.checked)}
/>
<span>{t("share.allowJournalNotes")}</span>
</label>
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.close")}
@@ -167,9 +300,28 @@ export function ShareDialog({
{shareGenerating ? t("share.generating") : t("share.generateLink")}
</button>
</div>
<div className="share-dialog-active-links">{renderManageLinks()}</div>
</div>
);
})()}
{shareToRevoke && (
<ConfirmModal
title={t("share.revoke")}
message={t("share.revokeConfirm", { person: getPersonLabel(shareToRevoke.takenBy) })}
confirmLabel={revokingShareToken === shareToRevoke.token ? t("share.revoking") : t("share.revoke")}
cancelLabel={t("common.cancel")}
onConfirm={async () => {
const revoked = await onRevokeShareLink(shareToRevoke.token);
if (revoked) {
setShareToRevoke(null);
}
}}
onCancel={closeRevokeConfirm}
isLoading={revokingShareToken === shareToRevoke.token}
confirmVariant="danger"
overlayClassName="nested-confirm"
/>
)}
</div>
</div>
);
+182 -4
View File
@@ -4,14 +4,17 @@
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
import { NotebookPen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useFeedback } from "../context/FeedbackContext";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { toggleDateInSet } from "../features/schedule/interactions";
import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
import { useEscapeKey } from "../hooks";
import { useEscapeKey, useModalHistory } from "../hooks";
import type { IntakeJournalEntry } from "../hooks/useIntakeJournal";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import {
allowsPillFormSelection,
@@ -26,12 +29,30 @@ import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
import { convertLiquidUsageToMl } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
import { MedicationAvatar } from "./MedicationAvatar";
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
async function readSharedJournalError(response: Response, fallbackMessage: string): Promise<string> {
try {
const data = (await response.json()) as { error?: string; code?: string };
if (typeof data.error === "string" && data.error.trim().length > 0) {
return data.error;
}
if (typeof data.code === "string" && data.code.trim().length > 0) {
return data.code;
}
} catch {
// Fall back to the supplied message when the response body is not JSON.
}
return fallbackMessage;
}
export function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const { showFeedback } = useFeedback();
const [data, setData] = useState<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -39,8 +60,15 @@ export function SharedSchedule() {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState<Set<string>>(new Set());
const mutationInFlightRef = useRef(0);
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [sharedJournalOpen, setSharedJournalOpen] = useState(false);
const [sharedJournalDoseId, setSharedJournalDoseId] = useState<string | null>(null);
const [sharedJournalEntry, setSharedJournalEntry] = useState<IntakeJournalEntry | null>(null);
const [sharedJournalLoading, setSharedJournalLoading] = useState(false);
const [sharedJournalSaving, setSharedJournalSaving] = useState(false);
const [sharedJournalError, setSharedJournalError] = useState<string | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false);
@@ -169,6 +197,107 @@ export function SharedSchedule() {
// Close lightbox on Escape key
useEscapeKey(!!lightboxImage, closeLightbox);
const closeSharedJournalEditor = useCallback(() => {
setSharedJournalOpen(false);
setSharedJournalDoseId(null);
setSharedJournalEntry(null);
setSharedJournalLoading(false);
setSharedJournalSaving(false);
setSharedJournalError(null);
}, []);
useModalHistory(sharedJournalOpen, "shared-intake-journal", closeSharedJournalEditor);
const openSharedJournalEditor = useCallback(
async (doseId: string) => {
if (!token || !data?.allowJournalNotes) {
return;
}
setSharedJournalOpen(true);
setSharedJournalDoseId(doseId);
setSharedJournalEntry(null);
setSharedJournalLoading(true);
setSharedJournalError(null);
try {
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
setSharedJournalEntry(null);
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.loadFailed")));
return;
}
const payload = (await response.json()) as { entry: IntakeJournalEntry };
setSharedJournalEntry(payload.entry);
setSharedJournalDoseIdsWithNotes((current) => {
const next = new Set(current);
if (payload.entry.note?.trim()) {
next.add(payload.entry.doseId);
} else {
next.delete(payload.entry.doseId);
}
return next;
});
} catch {
setSharedJournalEntry(null);
setSharedJournalError(t("journal.errors.loadFailed"));
} finally {
setSharedJournalLoading(false);
}
},
[data?.allowJournalNotes, t, token]
);
const saveSharedJournalNote = useCallback(
async (note: string) => {
if (!token || !sharedJournalDoseId) {
setSharedJournalError(t("journal.errors.noEventSelected"));
return false;
}
if (note.trim().length === 0) {
setSharedJournalError(t("journal.errors.emptySharedNote"));
return false;
}
setSharedJournalSaving(true);
setSharedJournalError(null);
try {
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(sharedJournalDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.saveFailed")));
return false;
}
const payload = (await response.json()) as { entry: IntakeJournalEntry };
setSharedJournalEntry(payload.entry);
setSharedJournalDoseIdsWithNotes((current) => {
const next = new Set(current);
if (payload.entry.note?.trim()) {
next.add(payload.entry.doseId);
} else {
next.delete(payload.entry.doseId);
}
return next;
});
return true;
} catch {
setSharedJournalError(t("journal.errors.saveFailed"));
return false;
} finally {
setSharedJournalSaving(false);
}
},
[sharedJournalDoseId, t, token]
);
// Handle browser back button to close lightbox
useEffect(() => {
function handlePopState() {
@@ -194,11 +323,13 @@ export function SharedSchedule() {
const taken = new Set<string>();
const automatic = new Set<string>();
const dismissed = new Set<string>();
const journalDoseIds = new Set<string>();
for (const d of data.doses as Array<{
doseId: string;
dismissed?: boolean;
skipped?: boolean;
takenSource?: string;
hasJournalNote?: boolean;
}>) {
if (d.skipped === true || d.dismissed === true) {
dismissed.add(d.doseId);
@@ -208,10 +339,14 @@ export function SharedSchedule() {
automatic.add(d.doseId);
}
}
if (d.hasJournalNote === true) {
journalDoseIds.add(d.doseId);
}
}
setTakenDoses(taken);
setAutomaticTakenDoses(automatic);
setDismissedDoses(dismissed);
setSharedJournalDoseIdsWithNotes(journalDoseIds);
}
} catch {
// Keep the current optimistic/shared state on transient read errors.
@@ -268,7 +403,7 @@ export function SharedSchedule() {
try {
const data = (await response.json()) as { code?: string };
if (data.code === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
} catch {
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
@@ -448,6 +583,9 @@ export function SharedSchedule() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const showSharedJournalAction = Boolean(data?.allowJournalNotes);
const canOpenSharedJournal = showSharedJournalAction && (options.isTaken || options.isSkipped);
const hasSharedJournalNote = sharedJournalDoseIdsWithNotes.has(options.doseId);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -486,10 +624,33 @@ export function SharedSchedule() {
</button>
);
const journalButton = showSharedJournalAction ? (
<span
className={!canOpenSharedJournal ? "tooltip-trigger" : undefined}
data-tooltip={!canOpenSharedJournal ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className={`dose-btn journal${hasSharedJournalNote ? " has-note" : ""}`}
onClick={() => {
if (canOpenSharedJournal) {
void openSharedJournalEditor(options.doseId);
}
}}
disabled={!canOpenSharedJournal}
title={canOpenSharedJournal ? t("journal.actions.note") : undefined}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
) : null;
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -641,7 +802,10 @@ export function SharedSchedule() {
}, [data, i18n.language]);
// Split into past, today, and future - matches main app logic
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
const pastDays = useMemo(() => {
const visiblePastDays = Math.max(1, data?.scheduleDays ?? 30);
return schedule.filter((d) => d.isPast).slice(-visiblePastDays);
}, [schedule, data?.scheduleDays]);
// Separate today from future days
const { todayDay, futureDays } = useMemo(() => {
@@ -901,6 +1065,7 @@ export function SharedSchedule() {
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>{pageTitle}</h1>
<p className="shared-schedule-boundary">{t("share.publicAccessHelp")}</p>
<div className="shared-schedule-header-actions">
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
@@ -1226,7 +1391,7 @@ export function SharedSchedule() {
const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id));
// Today: only collapse if manually collapsed or all taken
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose;
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose && !data.allowJournalNotes;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
@@ -1582,6 +1747,19 @@ export function SharedSchedule() {
/>
</div>
)}
<IntakeJournalModal
isOpen={sharedJournalOpen}
entry={sharedJournalEntry}
isLoading={sharedJournalLoading}
isSaving={sharedJournalSaving}
isDeleting={false}
error={sharedJournalError}
onClose={closeSharedJournalEditor}
onSave={saveSharedJournalNote}
onDelete={() => undefined}
allowDelete={false}
/>
</div>
);
}
+6 -2
View File
@@ -12,6 +12,7 @@ import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { personTagsMatch } from "../utils/person-tags";
import { getStockStatus } from "../utils/schedule";
export interface UserFilterModalProps {
@@ -72,7 +73,10 @@ export function UserFilterModal({
if (!selectedUser) return null;
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
const userMeds = meds.filter(
(medication) =>
!medication.isObsolete && (medication.takenBy || []).some((person) => personTagsMatch(person, selectedUser))
);
return (
<div
@@ -110,7 +114,7 @@ export function UserFilterModal({
// Get intakes relevant to this person
const personIntakes = getMedicationIntakes(med).filter(
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
(intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser)
);
return (
+3 -1
View File
@@ -7,8 +7,10 @@ export { DateInput } from "./DateInput";
export { DateTimeInput } from "./DateTimeInput";
export { default as ExportModal } from "./ExportModal";
export { FormNumberStepper } from "./FormNumberStepper";
export { ImportReviewModal } from "./ImportReviewModal";
export { IntakeJournalHistoryModal } from "./intake-journal/IntakeJournalHistoryModal";
export { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
export type { LightboxProps } from "./Lightbox";
export { Lightbox } from "./Lightbox";
export type { MedDetailModalProps } from "./MedDetailModal";
export { MedDetailModal } from "./MedDetailModal";
@@ -0,0 +1,191 @@
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry, IntakeJournalHistoryFilters } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import type { Medication } from "../../types";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { DateTimeInput } from "../DateTimeInput";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalHistoryModalProps {
isOpen: boolean;
entries: IntakeJournalEntry[];
filters: IntakeJournalHistoryFilters;
medications: Medication[];
isLoading: boolean;
error: string | null;
onClose: () => void;
onFilterChange: (patch: Partial<IntakeJournalHistoryFilters>) => void;
onReload: () => Promise<void> | void;
onResetFilters: () => void;
onReopen: (doseId: string) => Promise<void> | void;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalHistoryModal({
isOpen,
entries,
filters,
medications,
isLoading,
error,
onClose,
onFilterChange,
onReload,
onResetFilters,
onReopen,
}: IntakeJournalHistoryModalProps) {
const { t } = useTranslation();
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
if (!isOpen) {
return null;
}
let listContent: React.ReactNode;
if (isLoading) {
listContent = <div className="journal-modal-state">{t("journal.history.loading")}</div>;
} else if (entries.length === 0) {
listContent = <div className="journal-modal-state">{t("journal.history.empty")}</div>;
} else {
listContent = entries.map((entry) => (
<article key={entry.doseTrackingId} className="journal-history-entry">
<div className="journal-history-entry-main">
<div className="journal-history-entry-header">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{formatDisplayDateTime(entry.scheduledFor) ?? t("common.notAvailable")}</p>
</div>
</div>
<p className="journal-history-note">{entry.note ?? t("journal.history.noNote")}</p>
<div className="journal-history-meta">
<span>{t(entry.dismissed ? "journal.context.statusSkipped" : "journal.context.statusTaken")}</span>
<span>{getJournalSourceLabel(entry, t)}</span>
{entry.updatedAt && (
<span>
{t("journal.history.updatedAt", {
date: formatDisplayDateTime(entry.updatedAt) ?? entry.updatedAt,
})}
</span>
)}
</div>
</div>
<button type="button" className="primary small" onClick={() => void onReopen(entry.doseId)}>
{t("journal.history.reopen")}
</button>
</article>
));
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-history-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{t("journal.history.title")}</h2>
<p>{t("journal.history.description")}</p>
</div>
<div className="journal-history-filters">
<label className="journal-field" htmlFor="journal-history-medication">
<span>{t("journal.history.filters.medication")}</span>
<select
id="journal-history-medication"
className="select-field"
value={filters.medicationId ?? "all"}
onChange={(event) => {
const value = event.target.value;
onFilterChange({ medicationId: value === "all" ? null : Number(value) });
}}
>
<option value="all">{t("journal.history.filters.allMedications")}</option>
{medications.map((medication) => (
<option key={medication.id} value={medication.id}>
{medication.name}
</option>
))}
</select>
</label>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.from")}</span>
<DateTimeInput
value={filters.from}
onChange={(event) => onFilterChange({ from: event.target.value })}
step="60"
aria-label={t("journal.history.filters.from")}
placeholder={t("journal.history.filters.fromPlaceholder")}
/>
</div>
<div className="journal-field journal-date-filter">
<span>{t("journal.history.filters.to")}</span>
<DateTimeInput
value={filters.to}
onChange={(event) => onFilterChange({ to: event.target.value })}
step="60"
aria-label={t("journal.history.filters.to")}
placeholder={t("journal.history.filters.toPlaceholder")}
/>
</div>
</div>
<div className="journal-history-toolbar">
<button type="button" className="ghost small" onClick={onResetFilters}>
{t("journal.history.resetFilters")}
</button>
<button type="button" className="ghost small" onClick={() => void onReload()} disabled={isLoading}>
{t("journal.history.reload")}
</button>
</div>
{error && <div className="journal-inline-error">{error}</div>}
<div className="journal-history-list">{listContent}</div>
<div className="modal-footer journal-modal-footer">
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose}>
{t("common.close")}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../../hooks/useEscapeKey";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
import { useScrollLock } from "../../hooks/useScrollLock";
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
import { MedicationAvatar } from "../MedicationAvatar";
interface IntakeJournalModalProps {
isOpen: boolean;
entry: IntakeJournalEntry | null;
isLoading: boolean;
isSaving: boolean;
isDeleting: boolean;
error: string | null;
onClose: () => void;
onSave: (note: string) => Promise<boolean> | boolean;
onDelete: () => Promise<void> | void;
allowDelete?: boolean;
}
function formatDisplayDateTime(value: string | null): string | null {
if (!value) {
return null;
}
return formatDateTime(value, getNumericLocale());
}
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
if (entry.takenSource === "automatic") {
return t("journal.context.sourceAutomaticReminder");
}
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
}
export function IntakeJournalModal({
isOpen,
entry,
isLoading,
isSaving,
isDeleting,
error,
onClose,
onSave,
onDelete,
allowDelete = true,
}: IntakeJournalModalProps) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [showSavedState, setShowSavedState] = useState(false);
const activeDoseTrackingIdRef = useRef<number | null>(null);
const wasSavingRef = useRef(false);
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
useEffect(() => {
if (!isOpen) {
setNote("");
setShowSavedState(false);
activeDoseTrackingIdRef.current = null;
wasSavingRef.current = false;
return;
}
if (!entry) {
return;
}
setNote(entry.note ?? "");
if (activeDoseTrackingIdRef.current !== entry.doseTrackingId) {
activeDoseTrackingIdRef.current = entry.doseTrackingId;
setShowSavedState(false);
}
}, [entry, isOpen]);
useEffect(() => {
if (!isOpen) {
wasSavingRef.current = false;
return;
}
if (isSaving) {
setShowSavedState(false);
wasSavingRef.current = true;
return;
}
if (wasSavingRef.current) {
wasSavingRef.current = false;
if (entry && !error && note === (entry.note ?? "")) {
setShowSavedState(true);
}
}
}, [entry, error, isOpen, isSaving, note]);
if (!isOpen) {
return null;
}
const handleSave = async () => {
const saved = await onSave(note);
if (saved) {
onClose();
}
};
const scheduledForLabel = formatDisplayDateTime(entry?.scheduledFor ?? null);
const takenAtLabel = formatDisplayDateTime(entry?.takenAt ?? null);
const title = entry?.note ? t("journal.editor.editTitle") : t("journal.editor.addTitle");
const saveLabel = showSavedState ? t("common.saved") : t("common.save");
let bodyContent: React.ReactNode;
if (isLoading) {
bodyContent = <div className="journal-modal-state">{t("journal.editor.loading")}</div>;
} else if (entry) {
bodyContent = (
<>
<div className="journal-event-card">
<div className="journal-event-medication">
<MedicationAvatar name={entry.medicationName} size="sm" />
<div>
<strong>{entry.medicationName}</strong>
<p>{entry.dismissed ? t("journal.context.statusSkipped") : t("journal.context.statusTaken")}</p>
</div>
</div>
<div className="journal-event-grid">
<div>
<span>{t("journal.context.scheduledFor")}</span>
<strong>{scheduledForLabel ?? t("common.notAvailable")}</strong>
</div>
<div>
<span>{t("journal.context.takenAt")}</span>
<strong>{takenAtLabel ?? t("journal.context.notRecorded")}</strong>
</div>
<div>
<span>{t("journal.context.markedBy")}</span>
<strong>{entry.markedBy ?? t("journal.context.self")}</strong>
</div>
<div>
<span>{t("journal.context.source")}</span>
<strong>{getJournalSourceLabel(entry, t)}</strong>
</div>
</div>
</div>
<label className="journal-field" htmlFor="journal-note-input">
<span>{t("journal.editor.noteLabel")}</span>
<textarea
id="journal-note-input"
className="journal-note-input"
rows={7}
value={note}
onChange={(event) => {
setNote(event.target.value);
setShowSavedState(false);
}}
placeholder={t("journal.editor.notePlaceholder")}
maxLength={4000}
/>
</label>
{error && <div className="journal-inline-error">{error}</div>}
</>
);
} else {
bodyContent = <div className="journal-modal-state">{error ?? t("journal.errors.loadFailed")}</div>;
}
return (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<div
className="modal-content journal-modal"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") {
event.stopPropagation();
}
}}
>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
×
</button>
<div className="journal-modal-header">
<h2>{title}</h2>
<p>{t("journal.editor.description")}</p>
</div>
{bodyContent}
<div className="modal-footer journal-modal-footer">
<div className="footer-left">
{allowDelete && (
<button
type="button"
className="ghost"
onClick={() => void onDelete()}
disabled={isLoading || isSaving || isDeleting || !entry?.note}
>
{isDeleting ? t("journal.editor.deleting") : t("common.delete")}
</button>
)}
</div>
<div className="footer-right">
<button type="button" className="ghost" onClick={onClose} disabled={isSaving || isDeleting}>
{t("common.cancel")}
</button>
<button
type="button"
className="primary"
onClick={() => void handleSave()}
disabled={isLoading || isSaving || isDeleting || !entry}
>
{saveLabel}
</button>
</div>
</div>
</div>
</div>
);
}
+183 -21
View File
@@ -2,7 +2,15 @@ import type React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import {
useCollapsedDays,
useDoses,
useIntakeJournal,
useMedications,
useRefill,
useSettings,
useShare,
} from "../hooks";
import {
type Coverage,
type FormState,
@@ -13,7 +21,9 @@ import {
} from "../types";
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
import { log } from "../utils/logger";
import { mergePersonTags } from "../utils/person-tags";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
import { useFeedback } from "./FeedbackContext";
import { ShareContextProvider } from "./ShareContext";
// =============================================================================
@@ -44,6 +54,34 @@ export type GroupedDay = {
meds: DayMedEntry[];
};
export type ImportPreview = {
version: string;
exportedAt: string;
includeSensitiveData: boolean;
incoming: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
journalEntries: number;
imageCount: number;
hasSettings: boolean;
};
current: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
};
warnings: {
replacesExistingData: boolean;
regeneratesShareLinks: boolean;
containsImages: boolean;
containsSensitiveData: boolean;
};
};
export interface AppContextValue {
// From useMedications
meds: Medication[];
@@ -87,6 +125,29 @@ export interface AppContextValue {
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
// From useIntakeJournal
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: ReturnType<typeof useIntakeJournal>["journalEvent"];
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: ReturnType<typeof useIntakeJournal>["journalHistoryEntries"];
journalHistoryFilters: ReturnType<typeof useIntakeJournal>["journalHistoryFilters"];
journalHistoryLoading: boolean;
journalHistoryError: string | null;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<ReturnType<typeof useIntakeJournal>["journalHistoryFilters"]>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
// From useCollapsedDays
manuallyCollapsedDays: Set<string>;
manuallyExpandedDays: Set<string>;
@@ -99,13 +160,21 @@ export interface AppContextValue {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ReturnType<typeof useShare>["activeShareLinks"];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
@@ -188,6 +257,8 @@ export interface AppContextValue {
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
pendingImportData: unknown;
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
importPreview: ImportPreview | null;
setImportPreview: React.Dispatch<React.SetStateAction<ImportPreview | null>>;
importResult: {
medications: number;
doses: number;
@@ -245,12 +316,14 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function AppProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
// Compose hooks
const medications = useMedications();
const settingsHook = useSettings();
const doses = useDoses();
const intakeJournal = useIntakeJournal();
const collapsed = useCollapsedDays(user?.id);
const share = useShare();
const refill = useRefill();
@@ -295,6 +368,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const [showExportModal, setShowExportModal] = useState(false);
const [showImportConfirm, setShowImportConfirm] = useState(false);
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [importResult, setImportResult] = useState<{
medications: number;
doses: number;
@@ -326,6 +400,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications.clearMedicationsState();
settingsHook.resetSettingsState();
doses.clearDosesState();
intakeJournal.resetJournalState();
refill.clearRefillState();
share.resetShareDialogState();
@@ -351,6 +426,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settingsHook.loadSettings,
doses.clearDosesState,
doses.loadTakenDoses,
intakeJournal.resetJournalState,
refill.clearRefillState,
share.resetShareDialogState,
]);
@@ -442,8 +518,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort();
return mergePersonTags(medications.meds.flatMap((medication) => medication.takenBy || []));
}, [medications.meds]);
// Get worst stock status for a day's medications
@@ -658,9 +733,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
async (includeImages: boolean = true) => {
setExporting(true);
try {
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
credentials: "include",
});
const res = await authFetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`);
if (!res.ok) throw new Error("Export failed");
const data = await res.json();
@@ -682,7 +755,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
}
setExporting(false);
},
[t, user?.username]
[authFetch, t, user?.username]
);
// Handle file selection for import
@@ -692,24 +765,64 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
reader.onload = async (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.version || !data.exportedAt) {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
const res = await authFetch("/api/import/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const text = await res.text();
let previewResponse: { error?: string; preview?: ImportPreview } = {};
try {
previewResponse = text ? JSON.parse(text) : {};
} catch {
log.error("Import preview response parse error:", text);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok || !previewResponse.preview) {
setPendingImportData(null);
setImportPreview(null);
if (previewResponse.error === "Invalid import data format") {
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
return;
}
showFeedback({
message: `${t("exportImport.importError")}: ${previewResponse.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
setImportResult(null);
setPendingImportData(data);
setImportPreview(previewResponse.preview);
setShowImportConfirm(true);
} catch {
alert(t("exportImport.invalidFile"));
setPendingImportData(null);
setImportPreview(null);
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
}
};
reader.readAsText(file);
// Reset file input
e.target.value = "";
},
[t]
[authFetch, showFeedback, t]
);
// Confirm and execute import
@@ -719,10 +832,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm(false);
try {
const res = await fetch("/api/import", {
const res = await authFetch("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(pendingImportData),
});
@@ -744,12 +856,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
data = text ? JSON.parse(text) : {};
} catch {
log.error("Import response parse error:", text);
alert(`${t("exportImport.importError")}: Server returned invalid response`);
showFeedback({
message: `${t("exportImport.importError")}: Server returned invalid response`,
tone: "error",
});
return;
}
if (!res.ok) {
alert(`${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`);
showFeedback({
message: `${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`,
tone: "error",
});
return;
}
@@ -768,12 +886,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
doses.loadTakenDoses();
} catch (err) {
log.error("Import error:", err);
alert(t("exportImport.importError"));
showFeedback({ message: t("exportImport.importError"), tone: "error" });
} finally {
setPendingImportData(null);
setImportPreview(null);
setImporting(false);
}
setPendingImportData(null);
setImporting(false);
}, [pendingImportData, t, medications, settingsHook, doses]);
}, [authFetch, pendingImportData, t, medications, settingsHook, doses, showFeedback]);
// Compute settingsChanged
const settingsChanged = useMemo(() => {
@@ -815,13 +934,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -865,6 +992,29 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
undoDoseTaken: doses.undoDoseTaken,
undoDoseSkipped: doses.undoDoseSkipped,
// From useIntakeJournal
journalEditorOpen: intakeJournal.journalEditorOpen,
journalHistoryOpen: intakeJournal.journalHistoryOpen,
journalTargetDoseId: intakeJournal.journalTargetDoseId,
journalEvent: intakeJournal.journalEvent,
journalEventLoading: intakeJournal.journalEventLoading,
journalEventSaving: intakeJournal.journalEventSaving,
journalEventDeleting: intakeJournal.journalEventDeleting,
journalEventError: intakeJournal.journalEventError,
journalHistoryEntries: intakeJournal.journalHistoryEntries,
journalHistoryFilters: intakeJournal.journalHistoryFilters,
journalHistoryLoading: intakeJournal.journalHistoryLoading,
journalHistoryError: intakeJournal.journalHistoryError,
openJournalEditor: intakeJournal.openJournalEditor,
closeJournalEditor: intakeJournal.closeJournalEditor,
saveJournalNote: intakeJournal.saveJournalNote,
deleteJournalNote: intakeJournal.deleteJournalNote,
openJournalHistory: intakeJournal.openJournalHistory,
closeJournalHistory: intakeJournal.closeJournalHistory,
setJournalHistoryFilters: intakeJournal.setJournalHistoryFilters,
reloadJournalHistory: intakeJournal.reloadJournalHistory,
reopenJournalHistoryEntry: intakeJournal.reopenJournalHistoryEntry,
// From useCollapsedDays
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
manuallyExpandedDays: collapsed.manuallyExpandedDays,
@@ -877,13 +1027,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShareSelectedPerson: share.setShareSelectedPerson,
shareSelectedDays: share.shareSelectedDays,
setShareSelectedDays: share.setShareSelectedDays,
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
shareAllowJournalNotes: share.shareAllowJournalNotes,
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
shareGenerating: share.shareGenerating,
shareLink: share.shareLink,
setShareLink: share.setShareLink,
shareCopied: share.shareCopied,
setShareCopied: share.setShareCopied,
activeShareLinks: share.activeShareLinks,
activeSharesLoading: share.activeSharesLoading,
revokingShareToken: share.revokingShareToken,
openShareDialog,
generateShareLink: share.generateShareLink,
revokeShareLink: share.revokeShareLink,
copyShareLink: share.copyShareLink,
closeShareDialog: share.closeShareDialog,
resetShareDialogState: share.resetShareDialogState,
@@ -970,6 +1128,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
setShowImportConfirm,
pendingImportData,
setPendingImportData,
importPreview,
setImportPreview,
importResult,
setImportResult,
handleExport,
@@ -981,6 +1141,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
medications,
settingsHook,
doses,
intakeJournal,
collapsed,
share,
refill,
@@ -1017,6 +1178,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showExportModal,
showImportConfirm,
pendingImportData,
importPreview,
importResult,
handleExport,
handleImportFileSelect,
+103
View File
@@ -0,0 +1,103 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export type FeedbackTone = "info" | "success" | "warning" | "error";
type FeedbackNotice = {
id: number;
message: string;
tone: FeedbackTone;
durationMs: number;
};
type FeedbackContextValue = {
showFeedback: (options: { message: string; tone?: FeedbackTone; durationMs?: number }) => void;
dismissFeedback: (id: number) => void;
clearFeedback: () => void;
};
const noop = () => {};
const defaultValue: FeedbackContextValue = {
showFeedback: noop,
dismissFeedback: noop,
clearFeedback: noop,
};
const FeedbackContext = createContext<FeedbackContextValue>(defaultValue);
export function FeedbackProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [notices, setNotices] = useState<FeedbackNotice[]>([]);
const nextIdRef = useRef(1);
const timeoutMapRef = useRef<Map<number, number>>(new Map());
const dismissFeedback = useCallback((id: number) => {
const timeoutId = timeoutMapRef.current.get(id);
if (typeof timeoutId === "number") {
window.clearTimeout(timeoutId);
timeoutMapRef.current.delete(id);
}
setNotices((current) => current.filter((notice) => notice.id !== id));
}, []);
const clearFeedback = useCallback(() => {
for (const timeoutId of timeoutMapRef.current.values()) {
window.clearTimeout(timeoutId);
}
timeoutMapRef.current.clear();
setNotices([]);
}, []);
const showFeedback = useCallback(
({ message, tone = "info", durationMs = 5000 }: { message: string; tone?: FeedbackTone; durationMs?: number }) => {
const id = nextIdRef.current++;
setNotices((current) => [...current, { id, message, tone, durationMs }].slice(-3));
const timeoutId = window.setTimeout(() => {
dismissFeedback(id);
}, durationMs);
timeoutMapRef.current.set(id, timeoutId);
},
[dismissFeedback]
);
useEffect(() => () => clearFeedback(), [clearFeedback]);
const value = useMemo(
() => ({
showFeedback,
dismissFeedback,
clearFeedback,
}),
[showFeedback, dismissFeedback, clearFeedback]
);
return (
<FeedbackContext.Provider value={value}>
{children}
<div className="app-feedback-stack" aria-live="polite" aria-atomic="false">
{notices.map((notice) => (
<div
key={notice.id}
className={`app-feedback app-feedback-${notice.tone}`}
role={notice.tone === "error" ? "alert" : "status"}
>
<div className="app-feedback-message">{notice.message}</div>
<button
type="button"
className="app-feedback-close"
onClick={() => dismissFeedback(notice.id)}
aria-label={t("common.close")}
>
×
</button>
</div>
))}
</div>
</FeedbackContext.Provider>
);
}
export function useFeedback() {
return useContext(FeedbackContext);
}
+8
View File
@@ -7,13 +7,21 @@ type ShareContextValue = {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: import("../hooks/useShare").ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: () => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
+2
View File
@@ -2,6 +2,8 @@
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
export { AppProvider, useAppContext } from "./AppContext";
export type { FeedbackTone } from "./FeedbackContext";
export { FeedbackProvider, useFeedback } from "./FeedbackContext";
export type { ShareContextValue } from "./ShareContext";
export { ShareContextProvider, useShareContext } from "./ShareContext";
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
+2
View File
@@ -5,6 +5,8 @@ export { useCollapsedDays } from "./useCollapsedDays";
export type { UseDosesReturn } from "./useDoses";
export { useDoses } from "./useDoses";
export { useEscapeKey } from "./useEscapeKey";
export type { IntakeJournalEntry, IntakeJournalHistoryFilters, UseIntakeJournalReturn } from "./useIntakeJournal";
export { useIntakeJournal } from "./useIntakeJournal";
export {
createMedicationEnrichmentState,
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
+25 -15
View File
@@ -4,6 +4,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
export interface UseDosesReturn {
takenDoses: Set<string>;
@@ -25,6 +27,8 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const { t } = useTranslation();
const { authFetch } = useAuth();
const { showFeedback } = useFeedback();
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());
@@ -48,7 +52,7 @@ export function useDoses(): UseDosesReturn {
if (mutationInFlightRef.current > 0) return;
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
const res = await authFetch("/api/doses/taken");
if (res.ok) {
// Double-check no mutation started while we were fetching
if (mutationInFlightRef.current > 0) return;
@@ -79,7 +83,7 @@ export function useDoses(): UseDosesReturn {
} catch {
// Don't reset on error - keep current state
}
}, [clearDosesState]);
}, [authFetch, clearDosesState]);
// Poll for taken doses from server (works with or without auth)
useEffect(() => {
@@ -164,15 +168,14 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
const response = await fetch("/api/doses/taken", {
const response = await authFetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
alert(t("common.outOfStockTakeBlocked"));
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
}
throw new Error("Failed to mark dose as taken");
}
@@ -220,7 +223,17 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
[
authFetch,
dismissedDoses,
getErrorCode,
loadTakenDoses,
showFeedback,
t,
takenDoseSources,
takenDoseTimestamps,
takenDoses,
]
);
const markDoseSkipped = useCallback(
@@ -257,10 +270,9 @@ export function useDoses(): UseDosesReturn {
});
try {
const response = await fetch("/api/doses/skip", {
const response = await authFetch("/api/doses/skip", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
if (!response.ok) {
@@ -302,7 +314,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
[authFetch, dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
);
const undoDoseTaken = useCallback(
@@ -330,9 +342,8 @@ export function useDoses(): UseDosesReturn {
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
@@ -361,7 +372,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
[authFetch, loadTakenDoses, takenDoseSources, takenDoseTimestamps]
);
const undoDoseSkipped = useCallback(
@@ -376,9 +387,8 @@ export function useDoses(): UseDosesReturn {
});
try {
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
await authFetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
setDismissedDoses((prev) => {
@@ -393,7 +403,7 @@ export function useDoses(): UseDosesReturn {
loadTakenDoses();
}
},
[dismissedDoses, loadTakenDoses]
[authFetch, dismissedDoses, loadTakenDoses]
);
return {
+339
View File
@@ -0,0 +1,339 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useModalHistory } from "./useModalHistory";
export type IntakeJournalEntry = {
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: string;
takenAt: string | null;
dismissed: boolean;
takenSource: "manual" | "automatic";
markedBy: string | null;
note: string | null;
updatedAt: string | null;
createdAt: string | null;
};
export type IntakeJournalHistoryFilters = {
medicationId: number | null;
from: string;
to: string;
limit: number;
};
export interface UseIntakeJournalReturn {
journalEditorOpen: boolean;
journalHistoryOpen: boolean;
journalTargetDoseId: string | null;
journalEvent: IntakeJournalEntry | null;
journalEventLoading: boolean;
journalEventSaving: boolean;
journalEventDeleting: boolean;
journalEventError: string | null;
journalHistoryEntries: IntakeJournalEntry[];
journalHistoryFilters: IntakeJournalHistoryFilters;
journalHistoryLoading: boolean;
journalHistoryError: string | null;
resetJournalState: () => void;
openJournalEditor: (doseId: string) => Promise<void>;
closeJournalEditor: () => void;
saveJournalNote: (note: string) => Promise<boolean>;
deleteJournalNote: () => Promise<boolean>;
openJournalHistory: () => void;
closeJournalHistory: () => void;
setJournalHistoryFilters: (patch: Partial<IntakeJournalHistoryFilters>) => void;
reloadJournalHistory: () => Promise<void>;
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
}
const DEFAULT_HISTORY_FILTERS: IntakeJournalHistoryFilters = {
medicationId: null,
from: "",
to: "",
limit: 100,
};
async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
try {
const data = (await response.json()) as { error?: string; code?: string };
if (typeof data.error === "string" && data.error.trim().length > 0) {
return data.error;
}
if (typeof data.code === "string" && data.code.trim().length > 0) {
return data.code;
}
} catch {
// Fall back to the supplied message when the response body is not JSON.
}
return fallbackMessage;
}
function buildHistoryQuery(filters: IntakeJournalHistoryFilters): string {
const params = new URLSearchParams();
if (typeof filters.medicationId === "number") {
params.set("medicationId", String(filters.medicationId));
}
if (filters.from.trim().length > 0) {
params.set("from", filters.from.trim());
}
if (filters.to.trim().length > 0) {
params.set("to", filters.to.trim());
}
params.set("limit", String(filters.limit));
const query = params.toString();
return query.length > 0 ? `?${query}` : "";
}
export function useIntakeJournal(): UseIntakeJournalReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const [journalEditorOpen, setJournalEditorOpen] = useState(false);
const [journalHistoryOpen, setJournalHistoryOpen] = useState(false);
const [journalTargetDoseId, setJournalTargetDoseId] = useState<string | null>(null);
const [journalEvent, setJournalEvent] = useState<IntakeJournalEntry | null>(null);
const [journalEventLoading, setJournalEventLoading] = useState(false);
const [journalEventSaving, setJournalEventSaving] = useState(false);
const [journalEventDeleting, setJournalEventDeleting] = useState(false);
const [journalEventError, setJournalEventError] = useState<string | null>(null);
const [journalHistoryEntries, setJournalHistoryEntries] = useState<IntakeJournalEntry[]>([]);
const [journalHistoryFilters, setJournalHistoryFiltersState] =
useState<IntakeJournalHistoryFilters>(DEFAULT_HISTORY_FILTERS);
const [journalHistoryLoading, setJournalHistoryLoading] = useState(false);
const [journalHistoryError, setJournalHistoryError] = useState<string | null>(null);
const resetJournalState = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
setJournalEventError(null);
setJournalHistoryEntries([]);
setJournalHistoryFiltersState(DEFAULT_HISTORY_FILTERS);
setJournalHistoryLoading(false);
setJournalHistoryError(null);
}, []);
const loadJournalEvent = useCallback(
async (doseId: string) => {
setJournalEventLoading(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(doseId)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.loadFailed"));
setJournalEvent(null);
setJournalEventError(message);
return;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
} catch {
setJournalEvent(null);
setJournalEventError(t("journal.errors.loadFailed"));
} finally {
setJournalEventLoading(false);
}
},
[authFetch, t]
);
const loadJournalHistory = useCallback(
async (filters: IntakeJournalHistoryFilters) => {
setJournalHistoryLoading(true);
setJournalHistoryError(null);
try {
const response = await authFetch(`/api/intake-journal${buildHistoryQuery(filters)}`);
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.historyFailed"));
setJournalHistoryEntries([]);
setJournalHistoryError(message);
return;
}
const data = (await response.json()) as { entries: IntakeJournalEntry[] };
setJournalHistoryEntries(Array.isArray(data.entries) ? data.entries : []);
} catch {
setJournalHistoryEntries([]);
setJournalHistoryError(t("journal.errors.historyFailed"));
} finally {
setJournalHistoryLoading(false);
}
},
[authFetch, t]
);
useEffect(() => {
if (!journalHistoryOpen) {
return;
}
void loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, journalHistoryOpen, loadJournalHistory]);
const openJournalEditor = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
setJournalEditorOpen(true);
setJournalTargetDoseId(doseId);
setJournalEvent(null);
await loadJournalEvent(doseId);
},
[loadJournalEvent]
);
const closeJournalEditor = useCallback(() => {
setJournalEditorOpen(false);
setJournalTargetDoseId(null);
setJournalEvent(null);
setJournalEventError(null);
setJournalEventLoading(false);
setJournalEventSaving(false);
setJournalEventDeleting(false);
}, []);
const saveJournalNote = useCallback(
async (note: string) => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventSaving(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.saveFailed"));
setJournalEventError(message);
return false;
}
const data = (await response.json()) as { entry: IntakeJournalEntry };
setJournalEvent(data.entry);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.saveFailed"));
return false;
} finally {
setJournalEventSaving(false);
}
},
[authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]
);
const deleteJournalNote = useCallback(async () => {
if (!journalTargetDoseId) {
setJournalEventError(t("journal.errors.noEventSelected"));
return false;
}
setJournalEventDeleting(true);
setJournalEventError(null);
try {
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
method: "DELETE",
});
if (!response.ok) {
const message = await readErrorMessage(response, t("journal.errors.deleteFailed"));
setJournalEventError(message);
return false;
}
setJournalEvent((previous) =>
previous ? { ...previous, note: null, updatedAt: null, createdAt: null } : previous
);
if (journalHistoryOpen) {
void loadJournalHistory(journalHistoryFilters);
}
return true;
} catch {
setJournalEventError(t("journal.errors.deleteFailed"));
return false;
} finally {
setJournalEventDeleting(false);
}
}, [authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]);
const openJournalHistory = useCallback(() => {
setJournalEditorOpen(false);
setJournalHistoryOpen(true);
setJournalHistoryError(null);
}, []);
const closeJournalHistory = useCallback(() => {
setJournalHistoryOpen(false);
setJournalHistoryError(null);
}, []);
useModalHistory(journalEditorOpen, "intake-journal-editor", closeJournalEditor);
useModalHistory(journalHistoryOpen, "intake-journal-history", closeJournalHistory);
const updateJournalHistoryFilters = useCallback((patch: Partial<IntakeJournalHistoryFilters>) => {
setJournalHistoryFiltersState((previous) => ({
...previous,
...patch,
}));
}, []);
const reloadJournalHistory = useCallback(async () => {
await loadJournalHistory(journalHistoryFilters);
}, [journalHistoryFilters, loadJournalHistory]);
const reopenJournalHistoryEntry = useCallback(
async (doseId: string) => {
setJournalHistoryOpen(false);
await openJournalEditor(doseId);
},
[openJournalEditor]
);
return {
journalEditorOpen,
journalHistoryOpen,
journalTargetDoseId,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
resetJournalState,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters: updateJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
};
}
+3 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters";
import { normalizeWeekdays } from "../utils/intake-schedule";
import { personTagsMatch } from "../utils/person-tags";
export const defaultBlister = (): FormBlister => {
const now = new Date();
@@ -488,7 +489,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
const addTakenByPerson = useCallback(
(name: string) => {
const trimmed = name.trim();
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
const alreadyExists = form.takenBy.some((person) => personTagsMatch(person, trimmed));
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !alreadyExists) {
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
}
setTakenByInput("");
+10 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Medication } from "../types";
export interface UseMedicationsReturn {
@@ -16,6 +17,7 @@ export interface UseMedicationsReturn {
}
export function useMedications(): UseMedicationsReturn {
const { authFetch } = useAuth();
const [meds, setMeds] = useState<Medication[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -30,20 +32,20 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => {
setLoading(true);
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
authFetch("/api/medications?includeObsolete=true")
.then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([]))
.finally(() => setLoading(false));
}, []);
}, [authFetch]);
const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
if (editingId === id) resetForm();
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
const uploadMedImage = useCallback(
@@ -53,10 +55,9 @@ export function useMedications(): UseMedicationsReturn {
formData.append("file", file);
try {
const res = await fetch(`/api/medications/${medId}/image`, {
const res = await authFetch(`/api/medications/${medId}/image`, {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
let code = "UNKNOWN";
@@ -86,15 +87,15 @@ export function useMedications(): UseMedicationsReturn {
setUploadingImage(false);
}
},
[loadMeds]
[authFetch, loadMeds]
);
const deleteMedImage = useCallback(
async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
await authFetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
loadMeds();
},
[loadMeds]
[authFetch, loadMeds]
);
return {
+4 -3
View File
@@ -19,14 +19,15 @@ export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () =
useEffect(() => {
if (!isOpen) return;
const handlePopState = () => {
const handlePopState = (event: PopStateEvent) => {
if (pushedRef.current) {
pushedRef.current = false;
onClose();
event.stopImmediatePropagation();
}
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
window.addEventListener("popstate", handlePopState, { capture: true });
return () => window.removeEventListener("popstate", handlePopState, true);
}, [isOpen, onClose]);
}
+20 -17
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../components/Auth";
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import {
getMedTotal,
@@ -55,6 +56,7 @@ export interface UseRefillReturn {
}
export function useRefill(): UseRefillReturn {
const { authFetch } = useAuth();
// Refill state
const [showRefillModal, setShowRefillModal] = useState(false);
const [refillPacks, setRefillPacks] = useState(1);
@@ -93,19 +95,22 @@ export function useRefill(): UseRefillReturn {
}, [resetRefillForm]);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
try {
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
const loadRefillHistory = useCallback(
async (medId: number) => {
try {
const res = await authFetch(`/api/medications/${medId}/refills`);
if (res.ok) {
const data = await res.json();
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
} else {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
} catch {
setRefillHistory([]);
}
}, []);
},
[authFetch]
);
// Submit a refill
const submitRefill = useCallback(
@@ -119,10 +124,9 @@ export function useRefill(): UseRefillReturn {
if (refillPacks < 1 && refillLoose < 1) return;
setRefillSaving(true);
try {
const res = await fetch(`/api/medications/${medId}/refill`, {
const res = await authFetch(`/api/medications/${medId}/refill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
packsAdded: refillPacks,
loosePillsAdded: refillLoose,
@@ -162,7 +166,7 @@ export function useRefill(): UseRefillReturn {
}
setRefillSaving(false);
},
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
[authFetch, refillPacks, refillLoose, showRefillModal, loadRefillHistory]
);
// Submit a stock correction - user says how many pills they have RIGHT NOW
@@ -282,10 +286,9 @@ export function useRefill(): UseRefillReturn {
}
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
const res = await authFetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patchBody),
});
if (res.ok) {
@@ -301,7 +304,7 @@ export function useRefill(): UseRefillReturn {
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
[authFetch, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
@@ -28,6 +28,27 @@ export function useScheduleController() {
markDoseSkipped: ctx.markDoseSkipped,
undoDoseTaken: ctx.undoDoseTaken,
undoDoseSkipped: ctx.undoDoseSkipped,
journalEditorOpen: ctx.journalEditorOpen,
journalHistoryOpen: ctx.journalHistoryOpen,
journalTargetDoseId: ctx.journalTargetDoseId,
journalEvent: ctx.journalEvent,
journalEventLoading: ctx.journalEventLoading,
journalEventSaving: ctx.journalEventSaving,
journalEventDeleting: ctx.journalEventDeleting,
journalEventError: ctx.journalEventError,
journalHistoryEntries: ctx.journalHistoryEntries,
journalHistoryFilters: ctx.journalHistoryFilters,
journalHistoryLoading: ctx.journalHistoryLoading,
journalHistoryError: ctx.journalHistoryError,
openJournalEditor: ctx.openJournalEditor,
closeJournalEditor: ctx.closeJournalEditor,
saveJournalNote: ctx.saveJournalNote,
deleteJournalNote: ctx.deleteJournalNote,
openJournalHistory: ctx.openJournalHistory,
closeJournalHistory: ctx.closeJournalHistory,
setJournalHistoryFilters: ctx.setJournalHistoryFilters,
reloadJournalHistory: ctx.reloadJournalHistory,
reopenJournalHistoryEntry: ctx.reopenJournalHistoryEntry,
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
manuallyExpandedDays: ctx.manuallyExpandedDays,
toggleDayCollapse: ctx.toggleDayCollapse,
+144 -29
View File
@@ -3,12 +3,25 @@
// =============================================================================
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import type { Medication } from "../types";
import { withCorrelation } from "../utils/correlation";
import { log } from "../utils/logger";
const SHARE_ALL_VALUE = "all";
export interface ActiveShareLink {
token: string;
takenBy: string;
scheduleDays: number;
createdAt: string;
expiresAt: string | null;
allowJournalNotes: boolean;
shareUrl: string;
}
export interface UseShareReturn {
showShareDialog: boolean;
sharePeople: string[];
@@ -16,54 +29,96 @@ export interface UseShareReturn {
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
shareSelectedDays: number;
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
shareSelectedExpiryDays: number | null;
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
shareAllowJournalNotes: boolean;
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
shareGenerating: boolean;
shareLink: string | null;
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
shareCopied: boolean;
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
activeShareLinks: ActiveShareLink[];
activeSharesLoading: boolean;
revokingShareToken: string | null;
openShareDialog: (meds: Medication[]) => void;
generateShareLink: () => Promise<void>;
revokeShareLink: (token: string) => Promise<boolean>;
copyShareLink: () => void;
closeShareDialog: () => void;
resetShareDialogState: () => void;
}
export function useShare(): UseShareReturn {
const { authFetch } = useAuth();
const { t } = useTranslation();
const { showFeedback } = useFeedback();
const [showShareDialog, setShowShareDialog] = useState(false);
const [sharePeople, setSharePeople] = useState<string[]>([]);
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
const [shareSelectedExpiryDays, setShareSelectedExpiryDays] = useState<number | null>(null);
const [shareAllowJournalNotes, setShareAllowJournalNotes] = useState(false);
const [shareGenerating, setShareGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string | null>(null);
const [shareCopied, setShareCopied] = useState(false);
const [activeShareLinks, setActiveShareLinks] = useState<ActiveShareLink[]>([]);
const [activeSharesLoading, setActiveSharesLoading] = useState(false);
const [revokingShareToken, setRevokingShareToken] = useState<string | null>(null);
const openShareDialog = useCallback((meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
const loadActiveShareLinks = useCallback(async () => {
setActiveSharesLoading(true);
try {
const response = await authFetch("/api/share");
const data = await response.json().catch(() => ({}));
if (!response.ok || !Array.isArray(data?.shareLinks)) {
setActiveShareLinks([]);
log.warn("[ShareDialog] Failed to load active share links", { status: response.status });
return;
}
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
setActiveShareLinks(data.shareLinks);
} catch (error) {
setActiveShareLinks([]);
log.error("[ShareDialog] Active share list request threw error", { error });
} finally {
setActiveSharesLoading(false);
}
}, []);
}, [authFetch]);
const openShareDialog = useCallback(
(meds: Medication[]) => {
setShowShareDialog(true);
window.history.pushState({ modal: "share" }, "");
setShareLink(null);
setShareCopied(false);
setShareSelectedPerson("");
setShareSelectedDays(30);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
void loadActiveShareLinks();
// Include both per-intake assignments and legacy medication-level assignments.
const uniquePeople = [
...new Set(
meds.flatMap((medication) => [
...(medication.intakes
?.map((intake) => intake.takenBy)
.filter((person): person is string => Boolean(person)) ?? []),
...(medication.takenBy || []),
])
),
]
.filter(Boolean)
.sort();
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
if (uniquePeople.length > 0) {
setShareSelectedPerson(uniquePeople[0]);
}
},
[loadActiveShareLinks]
);
const generateShareLink = useCallback(async () => {
if (!shareSelectedPerson) {
@@ -82,19 +137,24 @@ export function useShare(): UseShareReturn {
body: JSON.stringify({
takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
}),
},
"fe-share"
);
const res = await fetch("/api/share", init);
const res = await authFetch("/api/share", init);
if (res.ok) {
const data = await res.json();
const fullUrl = `${window.location.origin}/share/${data.token}`;
setShareLink(fullUrl);
void loadActiveShareLinks();
log.info("[ShareDialog] Share link ready", {
person: shareSelectedPerson,
days: shareSelectedDays,
expiryDays: shareSelectedExpiryDays,
allowJournalNotes: shareAllowJournalNotes,
reused: Boolean(data.reused),
correlationId,
});
@@ -106,15 +166,57 @@ export function useShare(): UseShareReturn {
error: err.error,
correlationId,
});
alert(err.error || "Failed to generate share link");
showFeedback({
message: err.error || t("share.generateFailed"),
tone: "error",
});
}
} catch (error) {
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
alert("Failed to generate share link");
showFeedback({ message: t("share.generateFailed"), tone: "error" });
} finally {
setShareGenerating(false);
}
}, [shareSelectedPerson, shareSelectedDays]);
}, [
authFetch,
loadActiveShareLinks,
shareAllowJournalNotes,
shareSelectedExpiryDays,
shareSelectedPerson,
shareSelectedDays,
showFeedback,
t,
]);
const revokeShareLink = useCallback(
async (token: string) => {
setRevokingShareToken(token);
try {
const response = await authFetch(`/api/share/${token}`, { method: "DELETE" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
showFeedback({
message: data.error || t("share.revokeFailed"),
tone: "error",
});
return false;
}
setActiveShareLinks((current) => current.filter((share) => share.token !== token));
if (shareLink?.endsWith(`/share/${token}`)) {
setShareLink(null);
setShareCopied(false);
}
return true;
} catch {
showFeedback({ message: t("share.revokeFailed"), tone: "error" });
return false;
} finally {
setRevokingShareToken(null);
}
},
[authFetch, shareLink, showFeedback, t]
);
const copyShareLink = useCallback(() => {
if (shareLink) {
@@ -168,6 +270,11 @@ export function useShare(): UseShareReturn {
setShowShareDialog(false);
setShareLink(null);
setShareCopied(false);
setShareSelectedExpiryDays(null);
setShareAllowJournalNotes(false);
setActiveShareLinks([]);
setActiveSharesLoading(false);
setRevokingShareToken(null);
}, []);
return {
@@ -177,13 +284,21 @@ export function useShare(): UseShareReturn {
setShareSelectedPerson,
shareSelectedDays,
setShareSelectedDays,
shareSelectedExpiryDays,
setShareSelectedExpiryDays,
shareAllowJournalNotes,
setShareAllowJournalNotes,
shareGenerating,
shareLink,
setShareLink,
shareCopied,
setShareCopied,
activeShareLinks,
activeSharesLoading,
revokingShareToken,
openShareDialog,
generateShareLink,
revokeShareLink,
copyShareLink,
closeShareDialog,
resetShareDialogState,
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Nachfüllen nötig"
}
},
"journal": {
"actions": {
"note": "Notiz",
"noteTakenOnly": "Notizen funktionieren nur für genommene oder uebersprungene Dosen.",
"history": "Journal-Verlauf",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Journal-Notiz hinzufügen",
"editTitle": "Journal-Notiz bearbeiten",
"description": "Halte fest, was bei dieser Einnahme passiert ist, ohne den bestehenden Einnahme- oder Überspringen-Status zu ändern.",
"loading": "Journal-Eintrag wird geladen...",
"noteLabel": "Journal-Notiz",
"notePlaceholder": "Was möchtest du zu dieser Einnahme festhalten?",
"saving": "Speichern...",
"deleting": "Löschen..."
},
"history": {
"title": "Journal-Verlauf",
"description": "Durchsuche gespeicherte Einnahme-Notizen nach Medikament oder Zeitraum und öffne einen Eintrag erneut im Bearbeitungsmodus.",
"loading": "Journal-Verlauf wird geladen...",
"empty": "Keine Journal-Einträge passen zu den aktuellen Filtern.",
"noNote": "Keine Notiz gespeichert.",
"reload": "Neu laden",
"resetFilters": "Filter zurücksetzen",
"reopen": "Notiz erneut öffnen",
"updatedAt": "Aktualisiert {{date}}",
"filters": {
"medication": "Medikament",
"allMedications": "Alle Medikamente",
"from": "Von",
"to": "Bis",
"fromPlaceholder": "Startdatum",
"toPlaceholder": "Enddatum"
}
},
"context": {
"scheduledFor": "Geplant für",
"takenAt": "Eingenommen um",
"markedBy": "Markiert von",
"source": "Markiert ueber",
"sourceOwnerApp": "Haupt-App",
"sourceSharedLink": "Geteilter Einnahme-Link",
"sourceAutomaticReminder": "Automatische Erinnerungslogik",
"statusTaken": "Eingenommen",
"statusSkipped": "Übersprungen",
"notRecorded": "Nicht erfasst",
"self": "Du"
},
"errors": {
"loadFailed": "Der Journal-Eintrag konnte nicht geladen werden.",
"historyFailed": "Der Journal-Verlauf konnte nicht geladen werden.",
"saveFailed": "Die Journal-Notiz konnte nicht gespeichert werden.",
"deleteFailed": "Die Journal-Notiz konnte nicht gelöscht werden.",
"emptySharedNote": "Geteilte Links koennen Journal-Notizen nicht leeren. Gib eine Notiz ein oder schliesse den Dialog.",
"noEventSelected": "Es ist kein Journal-Eintrag ausgewählt."
}
},
"table": {
"name": "Name",
"pills": "Tabletten",
@@ -604,10 +662,16 @@
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
"deleteAccountButton": "Ja, mein Konto löschen",
"connectionErrorTitle": "Verbindungsfehler",
"connectionErrorHelp": "Bitte prüfe, ob der Server läuft, und versuche es erneut.",
"sessionExpiredTitle": "Sitzung abgelaufen",
"sessionExpiredHelp": "Bitte melde dich erneut an, um mit deiner Besitzersitzung fortzufahren."
},
"common": {
"loading": "Wird geladen...",
"initializing": "Initialisierung...",
"retry": "Erneut versuchen",
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
@@ -632,6 +696,7 @@
"back": "Zurück",
"cancel": "Abbrechen",
"close": "Schließen",
"hide": "Ausblenden",
"edit": "Bearbeiten",
"view": "Ansehen",
"delete": "Löschen",
@@ -676,6 +741,13 @@
"allPeople": "Alle",
"selectPerson": "Person auswählen",
"selectPeriod": "Zeitraum auswählen",
"selectExpiry": "Link-Ablauf",
"allowJournalNotes": "Diesem geteilten Link das Anzeigen und Bearbeiten von Journal-Notizen erlauben",
"journalNotesEnabled": "Journal anzeigen/bearbeiten erlaubt",
"expiryNever": "Laeuft nicht ab",
"expiry7Days": "Laeuft in 7 Tagen ab",
"expiry30Days": "Laeuft in 30 Tagen ab",
"expiry90Days": "Laeuft in 90 Tagen ab",
"generateLink": "Link generieren",
"generating": "Wird generiert...",
"generateAnother": "Weiteren Link generieren",
@@ -685,9 +757,21 @@
"copyLink": "Link kopieren",
"copyOverviewLink": "Übersichts-Link kopieren",
"copied": "In Zwischenablage kopiert!",
"activeLinksTitle": "Aktive Teilen-Links",
"loadingActiveLinks": "Aktive Teilen-Links werden geladen...",
"noActiveLinks": "Noch keine aktiven Teilen-Links.",
"manageLinksSummary": "Aktive Teilen-Links verwalten",
"generateFailed": "Freigabelink konnte nicht erstellt werden",
"revokeFailed": "Freigabelink konnte nicht widerrufen werden",
"activeLinkMeta": "{{days}} Tage, erstellt {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} Tage, erstellt {{createdAt}}, Ablauf {{expiresAt}}",
"revoke": "Widerrufen",
"revoking": "Wird widerrufen...",
"revokeConfirm": "Den aktiven Teilen-Link fuer {{person}} widerrufen?",
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
"scheduleFor": "Zeitplan für",
"period": "Zeitraum",
"publicAccessHelp": "Dieser Teilen-Link zeigt nur den ausgewaehlten Zeitplan und geteilte Dosisaktionen. Einstellungen und voller Kontozugriff bleiben in der Haupt-App.",
"noSchedule": "Keine geplanten Einnahmen gefunden.",
"generatedBy": "Erstellt von",
"notFound": "Teilen-Link nicht gefunden",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Daten importieren?",
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
"confirmButtonEmpty": "Importieren",
"reviewDescription": "Prüfe den validierten Sicherungsinhalt, bevor deine aktuellen Installationsdaten ersetzt werden.",
"reviewDescriptionEmpty": "Prüfe den validierten Sicherungsinhalt, bevor er in diese Installation importiert wird.",
"incomingData": "Importdatei",
"currentData": "Aktuelle Daten",
"summaryCounts": "{{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
"formatVersion": "Formatversion: {{version}}",
"exportedAt": "Exportiert am: {{date}}",
"settingsIncluded": "Einstellungen enthalten",
"settingsConfigured": "Einstellungen aktuell konfiguriert",
"journalEntries": "{{count}} Journaleinträge",
"imageCount": "{{count}} eingebettete Bilder",
"warningListTitle": "Warnungen",
"warningReplaceData": "Deine aktuellen Medikamente, die Einnahmehistorie, Einstellungen und Teilen-Links werden ersetzt.",
"warningShareLinks": "Importierte Teilen-Links erhalten beim Wiederherstellen aus Sicherheitsgründen neue Tokens.",
"warningImages": "Eingebettete Bilder vergrößern den Import und können die Wiederherstellung verlängern.",
"warningSensitive": "Diese Sicherung enthält sensible Benachrichtigungsdaten.",
"backupFirst": "Aktuelle Sicherung zuerst herunterladen",
"backupHint": "Empfohlen: exportiere zuerst deine aktuellen Daten, bevor du den Import bestätigst.",
"cancelButton": "Abbrechen",
"exportSuccess": "Daten erfolgreich exportiert",
"importSuccess": "Daten erfolgreich importiert",
@@ -836,6 +938,9 @@
"button": "Bericht",
"title": "Medikamentenbericht",
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
"dateRange": "Zeitraum",
"from": "Von",
"until": "Bis",
"selectAll": "Alle auswählen",
"deselectAll": "Alle abwählen",
"activeMeds": "Aktive Medikamente",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Drucken)",
"generate": "Erstellen",
"regenerate": "Vorschau aktualisieren",
"generating": "Wird erstellt...",
"download": "Herunterladen",
"preview": "Vorschau",
"previewDescription": "Prüfe den generierten Bericht vor dem Export.",
"invalidDateRange": "Wähle einen gültigen Zeitraum.",
"error": "Der Bericht konnte nicht erstellt werden. Bitte versuche es erneut.",
"noSelection": "Wähle mindestens ein Medikament aus",
"filterByPerson": "Bericht für",
"allPeople": "Alle Personen",
"docTitle": "Medikamentenbericht",
"docGenerated": "Erstellt am",
"docRange": "Berichtszeitraum",
"docGeneral": "Allgemein",
"docCommercialName": "Handelsname",
"docGenericName": "Wirkstoff",
+113 -1
View File
@@ -102,6 +102,64 @@
"needsRefill": "Needs refill"
}
},
"journal": {
"actions": {
"note": "Note",
"noteTakenOnly": "Notes are only available for taken or skipped doses.",
"history": "Journal history",
"historyShort": "Journal"
},
"editor": {
"addTitle": "Add journal note",
"editTitle": "Edit journal note",
"description": "Capture what happened for this intake without changing the existing take or skip status.",
"loading": "Loading journal entry...",
"noteLabel": "Journal note",
"notePlaceholder": "What should you remember about this intake?",
"saving": "Saving...",
"deleting": "Deleting..."
},
"history": {
"title": "Journal history",
"description": "Browse saved intake notes by medication or date, then reopen an entry in edit mode.",
"loading": "Loading journal history...",
"empty": "No journal entries match the current filters.",
"noNote": "No note saved.",
"reload": "Reload",
"resetFilters": "Reset filters",
"reopen": "Reopen note",
"updatedAt": "Updated {{date}}",
"filters": {
"medication": "Medication",
"allMedications": "All medications",
"from": "From",
"to": "To",
"fromPlaceholder": "Start date",
"toPlaceholder": "End date"
}
},
"context": {
"scheduledFor": "Scheduled for",
"takenAt": "Taken at",
"markedBy": "Marked by",
"source": "Marked via",
"sourceOwnerApp": "Main app",
"sourceSharedLink": "Shared intake link",
"sourceAutomaticReminder": "Automatic reminder logic",
"statusTaken": "Taken",
"statusSkipped": "Skipped",
"notRecorded": "Not recorded",
"self": "You"
},
"errors": {
"loadFailed": "Journal entry could not be loaded.",
"historyFailed": "Journal history could not be loaded.",
"saveFailed": "Journal note could not be saved.",
"deleteFailed": "Journal note could not be deleted.",
"emptySharedNote": "Shared links cannot clear journal notes. Enter a note or close the dialog.",
"noEventSelected": "No journal entry is selected."
}
},
"table": {
"name": "Name",
"pills": "Pills",
@@ -604,10 +662,16 @@
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
"deleteAccountButton": "Yes, delete my account",
"connectionErrorTitle": "Connection Error",
"connectionErrorHelp": "Please check if the server is running and try again.",
"sessionExpiredTitle": "Session expired",
"sessionExpiredHelp": "Please sign in again to continue your owner session."
},
"common": {
"loading": "Loading...",
"initializing": "Initializing...",
"retry": "Retry",
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
@@ -632,6 +696,7 @@
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"hide": "Hide",
"edit": "Edit",
"view": "View",
"delete": "Delete",
@@ -676,6 +741,13 @@
"allPeople": "Everyone",
"selectPerson": "Select person",
"selectPeriod": "Select time period",
"selectExpiry": "Link expiry",
"allowJournalNotes": "Allow this shared link to view and edit journal notes",
"journalNotesEnabled": "Journal view/edit enabled",
"expiryNever": "Never expires",
"expiry7Days": "Expires in 7 days",
"expiry30Days": "Expires in 30 days",
"expiry90Days": "Expires in 90 days",
"generateLink": "Generate Link",
"generating": "Generating...",
"generateAnother": "Generate another link",
@@ -685,9 +757,21 @@
"copyLink": "Copy Link",
"copyOverviewLink": "Copy Overview Link",
"copied": "Copied to clipboard!",
"activeLinksTitle": "Active share links",
"loadingActiveLinks": "Loading active share links...",
"noActiveLinks": "No active share links yet.",
"manageLinksSummary": "Manage active share links",
"generateFailed": "Failed to generate share link",
"revokeFailed": "Failed to revoke share link",
"activeLinkMeta": "{{days}} days, created {{createdAt}}",
"activeLinkMetaWithExpiry": "{{days}} days, created {{createdAt}}, expires {{expiresAt}}",
"revoke": "Revoke",
"revoking": "Revoking...",
"revokeConfirm": "Revoke the active share link for {{person}}?",
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
"scheduleFor": "Schedule for",
"period": "Period",
"publicAccessHelp": "This shared link only exposes the selected schedule and shared dose actions. Owner settings and full account access stay in the main app.",
"noSchedule": "No scheduled doses found.",
"generatedBy": "Generated by",
"notFound": "Share link not found",
@@ -755,6 +839,24 @@
"confirmImportEmpty": "Import Data?",
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
"confirmButtonEmpty": "Import",
"reviewDescription": "Review the validated backup contents before replacing your current installation data.",
"reviewDescriptionEmpty": "Review the validated backup contents before importing them into this installation.",
"incomingData": "Import file",
"currentData": "Current data",
"summaryCounts": "{{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
"formatVersion": "Format version: {{version}}",
"exportedAt": "Exported at: {{date}}",
"settingsIncluded": "Settings included",
"settingsConfigured": "Settings currently configured",
"journalEntries": "{{count}} journal entries",
"imageCount": "{{count}} embedded images",
"warningListTitle": "Warnings",
"warningReplaceData": "Your current medications, dose history, settings, and share links will be replaced.",
"warningShareLinks": "Imported share links will get new tokens during restore for security.",
"warningImages": "Embedded images increase import size and may take longer to restore.",
"warningSensitive": "This backup includes sensitive notification data.",
"backupFirst": "Download current backup first",
"backupHint": "Recommended: export your current data before confirming the import.",
"cancelButton": "Cancel",
"exportSuccess": "Data exported successfully",
"importSuccess": "Data imported successfully",
@@ -836,6 +938,9 @@
"button": "Report",
"title": "Medication Report",
"description": "Generate a document with detailed medication information for your doctor or personal records.",
"dateRange": "Date range",
"from": "From",
"until": "Until",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"activeMeds": "Active Medications",
@@ -845,12 +950,19 @@
"formatMd": "Markdown (.md)",
"formatPdf": "PDF (Print)",
"generate": "Generate",
"regenerate": "Refresh preview",
"generating": "Generating...",
"download": "Download",
"preview": "Preview",
"previewDescription": "Review the generated report before exporting it.",
"invalidDateRange": "Choose a valid date range.",
"error": "Could not generate the report. Please try again.",
"noSelection": "Select at least one medication",
"filterByPerson": "Report for",
"allPeople": "Everyone",
"docTitle": "Medication Report",
"docGenerated": "Generated on",
"docRange": "Report range",
"docGeneral": "General",
"docCommercialName": "Commercial Name",
"docGenericName": "Generic Name",
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./styles/intake-journal.css";
import "./styles/modals-base.css";
import "./styles/share-dialog.css";
import "./styles/medication-workflows.css";
+137 -19
View File
@@ -3,11 +3,13 @@ import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
import { useAppContext } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import { useModalHistory } from "../hooks";
import {
allowsPillFormSelection,
getMedDisplayName,
@@ -75,7 +77,8 @@ const EMPTY_DOSE_SET = new Set<string>();
export function DashboardPage() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const location = useLocation();
const {
meds,
@@ -112,6 +115,26 @@ export function DashboardPage() {
openUserFilter,
openShareDialog,
openScheduleLightbox,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
stockThresholds,
loadMeds,
loadSettings,
@@ -121,6 +144,21 @@ export function DashboardPage() {
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const notificationFocusAppliedRef = useRef<string | null>(null);
const closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "dashboard-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "dashboard-obsolete", closeObsoleteConfirm);
const effectiveSkippedDoses =
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
@@ -333,9 +371,8 @@ export function DashboardPage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -344,14 +381,37 @@ export function DashboardPage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const renderDoseActionButtons = (options: {
doseId: string;
isTaken: boolean;
@@ -359,6 +419,7 @@ export function DashboardPage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -381,8 +442,35 @@ export function DashboardPage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
if (!canManageSkippedDoses) {
return takeButton;
return (
<>
{takeButton}
{journalButton}
</>
);
}
const skipButton = options.isSkipped ? (
@@ -405,6 +493,7 @@ export function DashboardPage() {
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -417,22 +506,20 @@ export function DashboardPage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
@@ -619,10 +706,9 @@ export function DashboardPage() {
};
});
const stockRes = await fetch("/api/reminder/send-email", {
const stockRes = await authFetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
@@ -647,10 +733,9 @@ export function DashboardPage() {
};
});
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
const prescriptionRes = await authFetch("/api/reminder/send-prescription", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
prescriptionLow,
@@ -913,6 +998,17 @@ export function DashboardPage() {
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
</select>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
<button
className="ghost share-btn icon-only tooltip-trigger"
@@ -1229,9 +1325,7 @@ export function DashboardPage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -1741,6 +1835,30 @@ export function DashboardPage() {
})}
</div>
)}
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
</div>
+19 -14
View File
@@ -11,6 +11,7 @@ import { MedicationDialogs } from "../components/medications/MedicationDialogs";
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
import { MedicationListSection } from "../components/medications/MedicationListSection";
import { useAppContext, useUnsavedChanges } from "../context";
import { useFeedback } from "../context/FeedbackContext";
import {
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
MEDICATION_ENRICHMENT_LIMIT_STEP,
@@ -222,7 +223,8 @@ async function getMedicationEnrichmentErrorMessage(
export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
saving,
@@ -274,6 +276,7 @@ export function MedicationsPage() {
);
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const closeLightbox = useCallback(() => setLightboxImage(null), []);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
// Mobile modal state (declared early because it's used in useEffect below)
@@ -394,9 +397,7 @@ export function MedicationsPage() {
try {
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
credentials: "include",
});
const response = await authFetch(`/api/medication-enrichment/search?${params.toString()}`);
if (!response.ok) {
throw new Error(
@@ -458,7 +459,7 @@ export function MedicationsPage() {
}));
}
},
[medicationEnrichment.query, medicationEnrichment.results, t]
[authFetch, medicationEnrichment.query, medicationEnrichment.results, t]
);
const handlePendingMedicationImageSelection = useCallback(
@@ -489,6 +490,8 @@ export function MedicationsPage() {
const [readOnlyView, setReadOnlyView] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
useModalHistory(!!lightboxImage, "medication-image-lightbox", closeLightbox);
useModalHistory(showUnsavedConfirm, "medication-unsaved-confirm", handleCancelClose);
const [showNameValidation, setShowNameValidation] = useState(false);
useEffect(() => {
@@ -517,13 +520,13 @@ export function MedicationsPage() {
const loadAllMeds = useCallback(async () => {
try {
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
const res = await authFetch("/api/medications?includeObsolete=true");
const data = (await res.json()) as unknown;
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
} catch {
setAllMeds([]);
}
}, []);
}, [authFetch]);
useEffect(() => {
void loadAllMeds();
@@ -617,7 +620,7 @@ export function MedicationsPage() {
}));
try {
const response = await fetch("/api/medication-enrichment/enrich", {
const response = await authFetch("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -627,7 +630,6 @@ export function MedicationsPage() {
code: result.code,
source: result.source,
}),
credentials: "include",
});
if (!response.ok) {
@@ -699,7 +701,7 @@ export function MedicationsPage() {
}));
}
},
[form, medicationEnrichment.query, setForm, t]
[authFetch, form, medicationEnrichment.query, setForm, t]
);
const handleMedicationEnrichmentStrengthApply = useCallback(
@@ -1018,7 +1020,7 @@ export function MedicationsPage() {
async function markMedicationObsolete(id: number) {
try {
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/obsolete`, { method: "POST" });
if (editingId === id) {
handleResetForm();
}
@@ -1031,7 +1033,7 @@ export function MedicationsPage() {
async function reactivateMedication(id: number) {
try {
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
await authFetch(`/api/medications/${id}/reactivate`, { method: "POST" });
loadMeds();
await loadAllMeds();
} catch {
@@ -1229,7 +1231,10 @@ export function MedicationsPage() {
}
} catch (err) {
log.error("Save error:", err);
alert(err instanceof Error && err.message ? err.message : t("common.saveFailed"));
showFeedback({
message: err instanceof Error && err.message ? err.message : t("common.saveFailed"),
tone: "error",
});
}
setSaving(false);
@@ -2314,7 +2319,7 @@ export function MedicationsPage() {
onCancelDelete={handleCancelDelete}
showEditModal={showEditModal}
lightboxImage={lightboxImage}
onCloseLightbox={() => setLightboxImage(null)}
onCloseLightbox={closeLightbox}
showReportModal={showReportModal}
onCloseReportModal={() => setShowReportModal(false)}
medications={allMeds}
+3 -5
View File
@@ -33,7 +33,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
export function PlannerPage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
@@ -90,10 +90,9 @@ export function PlannerPage() {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
const rows = (await fetch("/api/medications/usage", {
const rows = (await authFetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
.then((res) => res.json())
@@ -158,10 +157,9 @@ export function PlannerPage() {
setPlannerEmailResult(null);
try {
const res = await fetch("/api/planner/send-email", {
const res = await authFetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
+145 -30
View File
@@ -1,12 +1,13 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Archive, Bell } from "lucide-react";
import { useState } from "react";
import { Archive, Bell, ClipboardList, NotebookPen } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useFeedback } from "../context/FeedbackContext";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { useScheduleController } from "../hooks";
import { useModalHistory, useScheduleController } from "../hooks";
import type { Coverage, IntakeUnit } from "../types";
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
@@ -71,7 +72,8 @@ function getDoseId(baseId: string, person: string | null): string {
export function SchedulePage() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, authFetch } = useAuth();
const { showFeedback } = useFeedback();
const {
meds,
settings,
@@ -96,12 +98,46 @@ export function SchedulePage() {
openUserFilter,
missedPastDoseIds,
loadMeds,
journalEditorOpen,
journalHistoryOpen,
journalEvent,
journalEventLoading,
journalEventSaving,
journalEventDeleting,
journalEventError,
journalHistoryEntries,
journalHistoryFilters,
journalHistoryLoading,
journalHistoryError,
openJournalEditor,
closeJournalEditor,
saveJournalNote,
deleteJournalNote,
openJournalHistory,
closeJournalHistory,
setJournalHistoryFilters,
reloadJournalHistory,
reopenJournalHistoryEntry,
} = useScheduleController();
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
const [clearingMissed, setClearingMissed] = useState(false);
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
const closeClearMissedConfirm = useCallback(() => {
if (!clearingMissed) {
setShowClearMissedConfirm(false);
}
}, [clearingMissed]);
const closeObsoleteConfirm = useCallback(() => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
}, []);
useModalHistory(showClearMissedConfirm, "schedule-clear-missed", closeClearMissedConfirm);
useModalHistory(showObsoleteConfirm, "schedule-obsolete", closeObsoleteConfirm);
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
const shouldHideNoScheduleStatusForTube = (
@@ -118,9 +154,8 @@ export function SchedulePage() {
setClearingMissed(true);
try {
const res = await fetch("/api/medications/dismiss-until", {
const res = await authFetch("/api/medications/dismiss-until", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
@@ -129,14 +164,37 @@ export function SchedulePage() {
}
await loadMeds();
setShowClearMissedConfirm(false);
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
showFeedback({
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
tone: "success",
});
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
} finally {
setClearingMissed(false);
}
};
const handleSaveJournalNote = async (note: string) => {
return saveJournalNote(note);
};
const handleDeleteJournalNote = async () => {
const deleted = await deleteJournalNote();
if (deleted) {
closeJournalEditor();
}
};
const handleResetJournalFilters = () => {
setJournalHistoryFilters({
medicationId: null,
from: "",
to: "",
limit: 100,
});
};
const requestMarkObsolete = (med: { id: number; name: string }) => {
setObsoleteCandidate(med);
setShowObsoleteConfirm(true);
@@ -145,22 +203,20 @@ export function SchedulePage() {
const handleConfirmMarkObsolete = async () => {
if (!obsoleteCandidate) return;
try {
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadMeds();
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
} catch {
alert(t("common.saveFailed"));
showFeedback({ message: t("common.saveFailed"), tone: "error" });
}
};
const handleCancelMarkObsolete = () => {
setShowObsoleteConfirm(false);
setObsoleteCandidate(null);
closeObsoleteConfirm();
};
const formatDoseUsageLabel = (
@@ -182,6 +238,7 @@ export function SchedulePage() {
isAutomaticallyTaken: boolean;
isEmpty: boolean;
}) => {
const journalUnavailable = !(options.isTaken || options.isSkipped);
const takeButton = options.isTaken ? (
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
{options.isAutomaticallyTaken && (
@@ -220,10 +277,33 @@ export function SchedulePage() {
</button>
);
const journalButton = (
<span
className={journalUnavailable ? "tooltip-trigger" : undefined}
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
>
<button
type="button"
className="dose-btn journal"
onClick={() => {
if (!journalUnavailable) {
void openJournalEditor(options.doseId);
}
}}
title={!journalUnavailable ? t("journal.actions.note") : undefined}
disabled={journalUnavailable}
>
<NotebookPen size={14} aria-hidden="true" />
<span className="dose-btn-label">{t("journal.actions.note")}</span>
</button>
</span>
);
return (
<>
{takeButton}
{skipButton}
{journalButton}
</>
);
};
@@ -233,19 +313,32 @@ export function SchedulePage() {
<article className="card schedule-full">
<div className="card-head">
<h2>{t("dashboard.schedules.title")}</h2>
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<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 className="card-head-actions">
<select
className="select-field schedule-days-select"
value={scheduleDays}
onChange={(e) => {
const val = Number(e.target.value);
setScheduleDays(val);
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
}}
>
<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>
<button
type="button"
className="ghost journal-history-button"
onClick={openJournalHistory}
aria-label={t("journal.actions.history")}
title={t("journal.actions.history")}
>
<ClipboardList size={16} aria-hidden="true" />
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
</button>
</div>
</div>
<div className="timeline">
{/* Past days (when expanded) — rendered above toggle */}
@@ -482,9 +575,7 @@ export function SchedulePage() {
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
onCancel={() => {
if (!clearingMissed) setShowClearMissedConfirm(false);
}}
onCancel={closeClearMissedConfirm}
isLoading={clearingMissed}
confirmVariant="warning"
/>
@@ -630,6 +721,30 @@ export function SchedulePage() {
);
})}
</div>
<IntakeJournalModal
isOpen={journalEditorOpen}
entry={journalEvent}
isLoading={journalEventLoading}
isSaving={journalEventSaving}
isDeleting={journalEventDeleting}
error={journalEventError}
onClose={closeJournalEditor}
onSave={handleSaveJournalNote}
onDelete={handleDeleteJournalNote}
/>
<IntakeJournalHistoryModal
isOpen={journalHistoryOpen}
entries={journalHistoryEntries}
filters={journalHistoryFilters}
medications={meds}
isLoading={journalHistoryLoading}
error={journalHistoryError}
onClose={closeJournalHistory}
onFilterChange={setJournalHistoryFilters}
onReload={reloadJournalHistory}
onResetFilters={handleResetJournalFilters}
onReopen={reopenJournalHistoryEntry}
/>
</article>
</section>
);
+37 -38
View File
@@ -1,12 +1,15 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { ExportModal, ImportReviewModal } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
export function SettingsPage() {
const { t, i18n } = useTranslation();
const { authFetch } = useAuth();
const [apiKeyToken, setApiKeyToken] = useState("");
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
const [apiKeyCopied, setApiKeyCopied] = useState(false);
@@ -37,15 +40,32 @@ export function SettingsPage() {
showImportConfirm,
setShowImportConfirm,
setPendingImportData,
importPreview,
setImportPreview,
handleImportConfirm,
importResult,
setImportResult,
meds,
} = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0;
const formattedImportPreviewDate = importPreview
? new Date(importPreview.exportedAt).toLocaleString(getSystemLocale(i18n.language))
: "";
const closeExportModal = useCallback(() => {
setShowExportModal(false);
}, [setShowExportModal]);
const closeImportReview = useCallback(() => {
setShowImportConfirm(false);
setPendingImportData(null);
setImportPreview(null);
}, [setImportPreview, setPendingImportData, setShowImportConfirm]);
useModalHistory(showExportModal, "export-options", closeExportModal);
useModalHistory(showImportConfirm, "import-review", closeImportReview);
let emailUnavailableReason: string | null = null;
if (settingsLoadError === "auth") {
emailUnavailableReason = t("settings.email.loadErrorAuth");
@@ -63,10 +83,9 @@ export function SettingsPage() {
setApiKeyCopied(false);
try {
const response = await fetch("/api/auth/api-keys", {
const response = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: "Default API Key",
scope: "write",
@@ -195,10 +214,9 @@ export function SettingsPage() {
onChange={(e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
fetch("/api/settings/language", {
authFetch("/api/settings/language", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ language: lang }),
});
}}
@@ -1142,38 +1160,19 @@ export function SettingsPage() {
</div>
)}
{/* Import Confirmation Modal */}
{showImportConfirm && (
<ConfirmModal
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
message={
hasExistingData ? (
<>
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
<p className="warning-text"> {t("exportImport.confirmImportWarning")}</p>
</>
) : (
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
)
}
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
cancelLabel={t("exportImport.cancelButton")}
onConfirm={handleImportConfirm}
onCancel={() => {
setShowImportConfirm(false);
setPendingImportData(null);
}}
confirmVariant={hasExistingData ? "danger" : "primary"}
/>
)}
<ImportReviewModal
isOpen={showImportConfirm}
importPreview={importPreview}
formattedExportedAt={formattedImportPreviewDate}
importing={importing}
exporting={exporting}
onClose={closeImportReview}
onBackup={() => handleExport(true)}
onConfirm={handleImportConfirm}
/>
{/* Export Options Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
onExport={handleExport}
exporting={exporting}
/>
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
</section>
);
}
+1
View File
@@ -5,6 +5,7 @@
Add new shared styles to the focused partial that owns the relevant domain.
============================================================================= */
@import url("./styles/foundation.css");
@import url("./styles/feedback.css");
@import url("./styles/app-surfaces.css");
@import url("./styles/settings-surfaces.css");
@import url("./styles/modal-detail.css");
+91
View File
@@ -284,6 +284,37 @@ a.about-version-link:hover {
margin: 0 0 1.25rem;
}
.report-range {
margin-bottom: 1.25rem;
}
.report-range h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 0.5rem;
}
.report-range-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.report-range-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-range-field .date-input-wrapper {
width: 100%;
}
/* Person filter */
.report-person-filter {
margin-bottom: 1.25rem;
@@ -448,6 +479,60 @@ a.about-version-link:hover {
display: none;
}
.report-error {
margin: 0 0 1rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
background: color-mix(in srgb, var(--danger-bg, #fee2e2) 75%, transparent);
color: var(--danger-text, #b91c1c);
font-size: 0.9rem;
}
.report-preview {
margin-bottom: 1.25rem;
padding: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
}
.report-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.report-preview-header h4 {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
}
.report-preview-desc {
margin: 0 0 0.75rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.report-preview-content {
margin: 0;
padding: 0.85rem;
border-radius: 8px;
background: var(--bg-primary);
border: 1px solid var(--border-secondary);
max-height: 280px;
overflow: auto;
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
/* Actions */
.report-actions {
display: flex;
@@ -456,3 +541,9 @@ a.about-version-link:hover {
padding-top: 0.75rem;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.report-range-grid {
grid-template-columns: 1fr;
}
}
+197 -30
View File
@@ -2919,48 +2919,74 @@ button.has-validation-error {
.time-row {
grid-template-columns: 1fr;
gap: 0.5rem;
min-width: 0;
}
.doses-col {
flex-direction: row;
flex-wrap: wrap;
flex-direction: column;
flex-wrap: nowrap;
width: 100%;
min-width: 0;
}
.dose-item {
flex: 1 1 auto;
min-width: 140px;
gap: 0.35rem;
padding: 0.35rem 0.3rem;
display: grid;
grid-template-columns: minmax(3.75rem, auto) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-width: 0;
gap: 0.45rem;
padding: 0.55rem 0.6rem;
}
.dose-time {
min-width: 42px;
padding-left: 0.2rem;
min-width: 0;
padding-left: 0;
white-space: nowrap;
}
.dose-usage {
line-height: 1.15;
min-width: 0;
}
.dose-checks {
gap: 2px;
grid-column: 1 / -1;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.3rem;
align-items: stretch;
}
.dose-item .reminder-icon {
justify-self: end;
}
.dose-person {
gap: 4px;
padding: 1px 4px;
width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 0.35rem;
padding: 0.28rem 0.35rem;
}
.dose-person .person-name {
flex: 1 1 auto;
min-width: 0;
max-width: 5.6rem;
margin-right: 0.35rem;
max-width: none;
margin-right: 0;
}
.dose-person > .tooltip-trigger {
display: inline-flex;
flex-shrink: 0;
}
.dose-person .dose-btn {
height: 22px;
min-height: 22px;
padding: 0 5px;
height: 26px;
min-height: 26px;
padding: 0 0.5rem;
font-size: 0.72rem;
}
@@ -2975,31 +3001,172 @@ button.has-validation-error {
.day-block {
padding: 0.75rem;
min-width: 0;
}
/* Use more horizontal space for schedule cards on phones */
.dashboard-schedules-section > .card {
padding-inline: 0.35rem;
overflow: visible;
.timeline,
.time-main,
.time-main .med-name,
.tag-row {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* Keep header controls aligned like other dashboard cards */
.dashboard-schedules-section .card-head {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding-inline: 0.65rem;
.time-main .med-name {
overflow-wrap: anywhere;
}
/* Keep schedule controls readable without exceeding phone width. */
.dashboard-schedules-section > .card,
.schedule-full {
width: 100%;
min-width: 0;
overflow: hidden;
}
.dashboard-schedules-section .card-head,
.schedule-full .card-head {
align-items: stretch;
}
.dashboard-schedules-section .card-head h2,
.schedule-full .card-head h2 {
width: 100%;
}
.dashboard-schedules-section .card-head-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
margin-left: 0;
gap: 0.5rem;
align-items: stretch;
}
.schedule-full .card-head-actions {
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
gap: 0.5rem;
align-items: stretch;
}
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex: 1 1 7.5rem;
width: auto;
min-width: 0;
max-width: none;
}
.dashboard-schedules-section .journal-history-button,
.schedule-full .journal-history-button {
flex: 1 1 7.5rem;
height: 2.75rem;
min-height: 2.75rem;
min-width: 0;
justify-content: center;
padding-block: 0;
}
.dashboard-schedules-section .journal-history-button span,
.schedule-full .journal-history-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-schedules-section .journal-history-label-full,
.schedule-full .journal-history-label-full {
display: none;
}
.dashboard-schedules-section .journal-history-label-short,
.schedule-full .journal-history-label-short {
display: inline;
}
.dashboard-schedules-section .share-btn.icon-only {
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
min-height: 2.75rem;
padding: 0;
align-self: stretch;
}
@media (max-width: 380px) {
.dashboard-schedules-section .schedule-days-select,
.schedule-full .schedule-days-select {
flex-basis: 100%;
}
.dashboard-schedules-section .journal-history-button {
flex-basis: calc(100% - 3.25rem);
}
}
.dashboard-schedules-section .day-block,
.schedule-full .day-block {
width: 100%;
min-width: 0;
max-width: 100%;
margin-inline: 0;
}
.day-divider {
min-width: 0;
}
.day-date {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.day-summary {
flex-shrink: 0;
white-space: nowrap;
}
.past-days-header,
.future-days-header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
min-width: 0;
}
.past-days-toggle,
.future-days-toggle,
.clear-missed-btn {
width: 100%;
min-width: 0;
}
.past-days-label,
.future-days-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.past-days-warning,
.past-days-complete,
.future-days-progress {
margin-left: auto;
flex-shrink: 0;
}
.dashboard-schedules-section .day-block {
margin-inline: -0.1rem;
.clear-missed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-chip {
+75
View File
@@ -0,0 +1,75 @@
.app-feedback-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: min(24rem, calc(100vw - 2rem));
z-index: 2100;
pointer-events: none;
}
.app-feedback {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24);
color: var(--text-primary);
backdrop-filter: blur(10px);
}
.app-feedback-info {
border-color: var(--accent);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--accent-bg));
}
.app-feedback-success {
border-color: var(--success);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--success-bg));
}
.app-feedback-warning {
border-color: var(--warning);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--warning-bg));
}
.app-feedback-error {
border-color: var(--danger);
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--danger-bg));
}
.app-feedback-message {
flex: 1;
font-size: 0.95rem;
line-height: 1.45;
}
.app-feedback-close {
padding: 0;
border: none;
background: transparent;
color: inherit;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
opacity: 0.7;
}
.app-feedback-close:hover {
opacity: 1;
}
@media (max-width: 640px) {
.app-feedback-stack {
right: 0.75rem;
left: 0.75rem;
bottom: 0.75rem;
width: auto;
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ body.modal-open {
}
.page {
max-width: 1200px;
max-width: 1440px;
margin: 0 auto;
padding: 2.5rem 1.5rem 1.5rem;
overflow-x: hidden;
+234
View File
@@ -0,0 +1,234 @@
/* =============================================================================
Intake Journal Modals
Owns the focused owner-only journal editor and history overlays.
============================================================================= */
.journal-modal,
.journal-history-modal {
max-width: 720px;
}
.journal-history-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
white-space: nowrap;
}
.journal-history-label-short {
display: none;
}
.dose-btn.journal {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--border-primary);
background: #ffffff;
color: #1e293b;
}
.dose-btn.journal:hover:not(:disabled) {
background: #f4f7fb;
}
.dose-btn.journal:disabled {
background: var(--bg-tertiary);
border-color: var(--border-primary);
color: var(--text-secondary);
filter: none;
}
.journal-modal-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
padding-right: 2.5rem;
}
.journal-modal-header h2 {
margin: 0;
}
.journal-modal-header p {
margin: 0;
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.5;
}
.journal-modal-state {
padding: 1rem;
border: 1px dashed var(--border-primary);
border-radius: 10px;
color: var(--text-secondary);
margin-bottom: 1rem;
}
.journal-event-card {
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 1rem;
background: var(--bg-primary);
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.journal-event-medication {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-event-medication p {
margin: 0.2rem 0 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.journal-event-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.journal-event-grid span,
.journal-field span {
display: block;
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-secondary);
}
.journal-event-grid strong {
display: block;
font-size: 0.92rem;
color: var(--text-primary);
}
.journal-field {
display: block;
margin-bottom: 1rem;
}
.journal-note-input,
.journal-history-modal .select-field {
width: 100%;
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-input);
color: var(--text-primary);
padding: 0.8rem 0.9rem;
font: inherit;
}
.journal-note-input {
resize: vertical;
min-height: 10rem;
line-height: 1.5;
}
.journal-inline-error {
margin-bottom: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.3);
background: rgba(127, 29, 29, 0.18);
color: var(--danger);
font-size: 0.9rem;
}
.journal-history-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.journal-date-filter .date-input-wrapper,
.journal-date-filter .date-input-display {
width: 100%;
}
.journal-history-toolbar {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 1rem;
}
.journal-history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.journal-history-entry {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: 12px;
border: 1px solid var(--border-primary);
background: var(--bg-primary);
align-items: flex-start;
}
.journal-history-entry-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.journal-history-entry-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.journal-history-entry-header p,
.journal-history-meta {
margin: 0;
color: var(--text-secondary);
font-size: 0.84rem;
}
.journal-history-note {
margin: 0;
white-space: pre-wrap;
line-height: 1.55;
color: var(--text-primary);
}
.journal-history-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
}
.journal-modal-footer {
padding: 1rem 0 0;
border-top: 1px solid var(--border-primary);
}
@media (max-width: 720px) {
.journal-history-filters,
.journal-event-grid {
grid-template-columns: 1fr;
}
.journal-history-entry {
flex-direction: column;
}
.journal-history-entry > button {
width: 100%;
}
}
+146 -2
View File
@@ -10,7 +10,7 @@
}
.shared-schedule-container {
max-width: 800px;
max-width: 1180px;
margin: 0 auto;
}
@@ -97,6 +97,14 @@
margin-bottom: 0.5rem;
}
.shared-schedule-boundary {
max-width: 34rem;
margin: 0 auto;
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.5;
}
.shared-schedule-period {
color: var(--text-secondary);
font-size: 1rem;
@@ -127,6 +135,10 @@
padding: 0.5rem 0;
}
.shared-schedule-page .tooltip-trigger > .dose-btn:disabled {
pointer-events: none;
}
.med-name-stack {
display: flex;
flex-direction: column;
@@ -326,17 +338,127 @@
@media (max-width: 600px) {
.shared-schedule-page {
padding: 1rem;
padding: 0.75rem;
overflow-x: hidden;
}
.shared-schedule-container,
.shared-schedule-section,
.shared-schedule-section .timeline,
.shared-schedule-section .day-block,
.shared-schedule-section .time-row,
.shared-schedule-section .time-main,
.shared-schedule-section .doses-col,
.shared-schedule-section .dose-item,
.shared-schedule-section .dose-checks,
.shared-schedule-section .dose-person {
width: 100%;
min-width: 0;
max-width: 100%;
}
.shared-schedule-header {
margin-bottom: 1.5rem;
padding-top: 0.25rem;
text-align: left;
}
.shared-schedule-header-actions {
top: 0;
right: 0;
}
.shared-schedule-header h1 {
font-size: 1.25rem;
line-height: 1.18;
padding-right: 3rem;
overflow-wrap: anywhere;
}
.shared-schedule-boundary {
margin-inline: 0;
font-size: 0.9rem;
}
.shared-schedule-period {
margin: 0.75rem 0 0;
font-size: 0.9rem;
}
.shared-timeline {
padding: 1rem;
}
.shared-schedule-section .timeline {
gap: 0.85rem;
}
.shared-schedule-section .day-block {
overflow: hidden;
border-radius: 12px;
padding: 0.75rem;
}
.shared-schedule-section .time-row {
gap: 0.65rem;
}
.shared-schedule-section .time-main .med-name {
overflow-wrap: anywhere;
}
.shared-schedule-section .doses-col {
gap: 0.55rem;
overflow: hidden;
}
.shared-schedule-section .dose-item {
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
gap: 0.45rem 0.6rem;
padding: 0.55rem 0.6rem;
overflow: hidden;
}
.shared-schedule-section .dose-checks {
grid-column: 1 / -1;
overflow: visible;
}
.shared-schedule-section .dose-person {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 0.4rem;
padding: 0.4rem;
overflow: visible;
}
.shared-schedule-section .dose-person .person-name {
grid-column: 1 / -1;
justify-self: stretch;
max-width: 100%;
margin-right: 0;
}
.shared-schedule-section .dose-person > .dose-btn,
.shared-schedule-section .dose-person > .tooltip-trigger {
min-width: 0;
margin-left: 0;
justify-self: end;
}
.shared-schedule-section .dose-person > .tooltip-trigger {
display: inline-flex;
justify-self: end;
max-width: 100%;
}
.shared-schedule-section .dose-person .dose-btn {
height: 28px;
min-height: 28px;
padding-inline: 0.55rem;
}
.shared-overview-table-wrap {
display: none;
}
@@ -346,6 +468,28 @@
}
}
@media (max-width: 640px) {
.shared-schedule-page .tooltip-trigger[data-tooltip]::after,
.shared-schedule-page .tooltip-trigger[data-tooltip]::before {
display: none;
}
.shared-schedule-page .tooltip-trigger.tooltip-active[data-tooltip]::after {
display: block;
position: fixed;
top: auto;
bottom: var(--tooltip-bottom, 50%);
left: 16px;
right: 16px;
transform: none;
width: auto;
max-width: none;
white-space: normal;
text-align: center;
z-index: 10000;
}
}
/* ── Desktop Edit Panel (two-column layout) ── */
.edit-sidebar {
display: none;
+62
View File
@@ -1290,4 +1290,66 @@
border: 1px solid var(--border-primary);
}
.import-review-modal {
max-width: 560px;
}
.import-review-modal h2 {
margin-bottom: 16px;
padding-right: 2rem;
}
.import-review-body {
display: grid;
gap: 16px;
}
.import-review-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.import-review-summary .action-card {
align-items: stretch;
}
.import-review-meta {
display: grid;
gap: 6px;
font-size: 0.9rem;
}
.import-review-warnings {
display: grid;
gap: 8px;
}
.import-review-warnings ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 1.25rem;
}
.import-review-footer {
justify-content: space-between;
gap: 12px;
padding: 1rem 0 0;
border-top: none;
}
.import-review-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
.import-review-summary {
grid-template-columns: 1fr;
}
}
/* Modal base styles moved to styles/modals-base.css */
+76
View File
@@ -66,6 +66,82 @@
border-color: var(--accent);
}
.share-dialog-active-links {
margin-top: 1rem;
}
.share-dialog-manage {
border: 1px solid var(--border-primary);
border-radius: 10px;
background: var(--bg-secondary);
overflow: hidden;
}
.share-dialog-manage-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.85rem 1rem;
font-weight: 600;
color: var(--text-primary);
background: transparent;
border: 0;
cursor: pointer;
text-align: left;
}
.share-dialog-manage-count {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
}
.share-dialog-manage-content {
padding: 0 1rem 1rem;
border-top: 1px solid var(--border-primary);
}
.share-active-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share-active-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding-top: 0.75rem;
}
.share-active-item + .share-active-item {
border-top: 1px solid var(--border-primary);
}
.share-active-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.share-link-inline {
font-weight: 600;
color: var(--accent);
text-decoration: none;
word-break: break-word;
}
.share-link-inline:hover {
text-decoration: underline;
}
.share-dialog-footer {
display: flex;
gap: 0.75rem;
+56 -8
View File
@@ -3,11 +3,34 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
const appTranslations: Record<string, string> = {
"auth.connectionErrorTitle": "Connection Error",
"auth.connectionErrorHelp": "Please check if the server is running and try again.",
"common.initializing": "Initializing...",
"common.loading": "Loading...",
"common.retry": "Retry",
};
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => appTranslations[key] ?? key,
i18n: {
language: "en",
changeLanguage: vi.fn(),
},
}),
};
});
type AuthStateMock = {
user: { id: number; username: string } | null;
authState: { authEnabled: boolean; needsSetup: boolean } | null;
loading: boolean;
authError: string | null;
sessionExpired?: boolean;
};
let authMock: AuthStateMock = {
@@ -15,6 +38,7 @@ let authMock: AuthStateMock = {
authState: { authEnabled: false, needsSetup: false },
loading: false,
authError: null,
sessionExpired: false,
};
let appContextMock: Record<string, unknown>;
@@ -58,7 +82,7 @@ vi.mock("../context", async () => {
};
});
vi.mock("../pages", () => ({
vi.mock("../pages/DashboardPage", () => ({
DashboardPage: () => {
const location = useLocation();
return (
@@ -68,10 +92,25 @@ vi.mock("../pages", () => ({
</div>
);
},
}));
vi.mock("../pages/MedicationsPage", () => ({
MedicationsPage: () => <div>medications-page</div>,
}));
vi.mock("../pages/PlannerPage", () => ({
PlannerPage: () => <div>planner-page</div>,
}));
vi.mock("../pages/SchedulePage", () => ({
SchedulePage: () => <div>schedule-page</div>,
}));
vi.mock("../pages/SettingsPage", () => ({
SettingsPage: () => <div>settings-page</div>,
}));
vi.mock("../pages/SharedOverviewPage", () => ({
SharedOverviewPage: () => <div>shared-overview-page</div>,
}));
@@ -141,12 +180,20 @@ describe("App", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 7,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
generateShareLink: vi.fn(),
revokeShareLink: vi.fn(),
copyShareLink: vi.fn(),
closeShareDialog: vi.fn(),
resetShareDialogState: vi.fn(),
@@ -200,6 +247,7 @@ describe("App", () => {
);
expect(screen.getByText("Connection Error")).toBeInTheDocument();
expect(screen.getByText("Please check if the server is running and try again.")).toBeInTheDocument();
expect(screen.getByText("Backend is unreachable")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
@@ -262,7 +310,7 @@ describe("App", () => {
expect(screen.getByText("auth-page")).toBeInTheDocument();
});
it("renders app shell when auth is disabled", () => {
it("renders app shell when auth is disabled", async () => {
render(
<MemoryRouter initialEntries={["/dashboard"]}>
<App />
@@ -270,10 +318,10 @@ describe("App", () => {
);
expect(screen.getByText("app-header")).toBeInTheDocument();
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
});
it("preserves notification query params when redirecting root to dashboard", () => {
it("preserves notification query params when redirecting root to dashboard", async () => {
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
render(
@@ -282,8 +330,8 @@ describe("App", () => {
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
expect(await screen.findByTestId("dashboard-location-search")).toHaveTextContent(search);
});
it("renders initializing state when auth state is missing", () => {
@@ -370,14 +418,14 @@ describe("App", () => {
expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
});
it("redirects unknown routes to dashboard", () => {
it("redirects unknown routes to dashboard", async () => {
render(
<MemoryRouter initialEntries={["/unknown-route"]}>
<App />
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
});
it("popstate closes image lightbox before other modals", () => {
@@ -132,6 +132,7 @@ describe("AuthProvider", () => {
await waitFor(() => {
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
});
@@ -865,6 +866,28 @@ describe("AuthProvider methods", () => {
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(false);
});
it("marks the session as expired when refreshUser cannot recover from 401", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) })
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toBeNull();
expect(result.current.sessionExpired).toBe(true);
});
it("updateProfile throws default message when backend has no error field", async () => {
@@ -0,0 +1,96 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ImportReviewModal } from "../../components/ImportReviewModal";
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
}),
};
});
const importPreview = {
version: "1.6",
exportedAt: "2026-05-21T10:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 2,
refillHistory: 3,
shareLinks: 4,
journalEntries: 1,
imageCount: 1,
hasSettings: true,
},
current: {
medications: 5,
doseHistory: 6,
refillHistory: 7,
shareLinks: 8,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsImages: true,
containsSensitiveData: true,
},
};
describe("ImportReviewModal", () => {
it("stays closed without an open preview", () => {
const { container } = render(
<ImportReviewModal
isOpen={false}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={vi.fn()}
onBackup={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(container.firstChild).toBeNull();
});
it("supports overlay, Escape, backup, and confirm actions", () => {
const onClose = vi.fn();
const onBackup = vi.fn();
const onConfirm = vi.fn();
const { container } = render(
<ImportReviewModal
isOpen={true}
importPreview={importPreview}
formattedExportedAt="May 21, 2026"
importing={false}
exporting={false}
onClose={onClose}
onBackup={onBackup}
onConfirm={onConfirm}
/>
);
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
fireEvent.click(container.querySelector(".modal-content") as Element);
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("exportImport.backupFirst"));
expect(onBackup).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText("exportImport.confirmButton"));
expect(onConfirm).toHaveBeenCalledTimes(1);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(container.querySelector(".modal-overlay") as Element);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { IntakeJournalModal } from "../../components/intake-journal/IntakeJournalModal";
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("../../components/MedicationAvatar", () => ({
MedicationAvatar: ({ name }: { name: string }) => <div>{name}</div>,
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "1-0-1760000000000-pillamn",
medicationId: 1,
medicationName: "Liquid Container",
scheduledFor: "2026-05-17T11:55:00.000Z",
takenAt: "2026-05-17T19:23:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "pillamn",
note: "",
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("IntakeJournalModal", () => {
it("closes after a successful save", async () => {
const onSave = vi.fn(async () => true);
const onClose = vi.fn();
const onDelete = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={onDelete}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
expect(onSave).toHaveBeenCalledWith("Shared note");
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it("keeps the modal open when save fails", async () => {
const onSave = vi.fn(async () => false);
const onClose = vi.fn();
const entry = buildEntry();
render(
<IntakeJournalModal
isOpen
entry={entry}
isLoading={false}
isSaving={false}
isDeleting={false}
error={null}
onClose={onClose}
onSave={onSave}
onDelete={vi.fn()}
/>
);
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
target: { value: "Shared note" },
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith("Shared note");
});
expect(onClose).not.toHaveBeenCalled();
});
});
+110 -57
View File
@@ -4,6 +4,28 @@ import ReportModal from "../../components/ReportModal";
import type { Medication } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({ authFetch: authFetchMock }),
}));
function getPreviewContent() {
const preview = document.querySelector(".report-preview-content");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview content to be rendered");
}
return preview.textContent ?? "";
}
function expectPreviewToBeVisible() {
const preview = document.querySelector(".report-preview");
if (!(preview instanceof HTMLElement)) {
throw new Error("Expected report preview to be rendered");
}
expect(preview).toBeInTheDocument();
}
function createMedication(overrides: Partial<Medication> = {}): Medication {
return {
id: 1,
@@ -24,6 +46,7 @@ function createMedication(overrides: Partial<Medication> = {}): Medication {
describe("ReportModal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
it("renders and closes when cancel is clicked", () => {
@@ -35,35 +58,41 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates text report and closes modal", async () => {
it("generates txt and md previews in-app without closing the modal", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
for (const format of ["txt", "md"] as const) {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
1: {
dosesTaken: 2,
automaticDosesTaken: 0,
dosesSkipped: 0,
firstDoseAt: "2026-01-01T08:00:00.000Z",
lastDoseAt: "2026-01-02T08:00:00.000Z",
refills: [],
},
}),
});
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const view = render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
fireEvent.click(
screen.getByRole("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
);
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalled();
await waitFor(() => {
expectPreviewToBeVisible();
});
expect(screen.getByRole("button", { name: /report\.download/i })).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(getPreviewContent()).toContain("report.docTitle");
view.unmount();
}
});
it("renders shared formatter output in exported text reports", async () => {
@@ -99,18 +128,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
expect(blob).toBeInstanceOf(Blob);
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain(formatDate("2026-02-01"));
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports bottle current stock separately from configured capacity", async () => {
@@ -151,16 +177,15 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docTotalCapacity: 100");
expect(content).toContain("report.docCurrentStock: 70 common.pills");
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("exports injection refill history with injection unit wording", async () => {
@@ -205,15 +230,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
expectPreviewToBeVisible();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
const content = getPreviewContent();
expect(content).toContain("report.docCurrentStock: 6 common.injections");
expect(content).toContain("+3 common.injections");
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
});
it("generates printable report when PDF format is selected", async () => {
@@ -288,14 +312,17 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
expect(screen.getAllByRole("checkbox", { name: "Alice" })).toHaveLength(1);
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
expect(screen.getByText("Alice Med")).toBeInTheDocument();
expect(screen.getByText("Alice Lower")).toBeInTheDocument();
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
@@ -335,7 +362,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -345,15 +373,14 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2], takenByFilter: ["Alice"] });
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
authFetchMock.mockClear();
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
firstRender.unmount();
render(
@@ -362,7 +389,8 @@ describe("ReportModal", () => {
onClose={onClose}
medications={[
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
]}
/>
);
@@ -370,17 +398,16 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
})
);
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
const body = JSON.parse((requestInit?.body as string) ?? "{}");
expect(body).toMatchObject({ medicationIds: [1, 2, 3] });
expect(body).not.toHaveProperty("takenByFilter");
expect(typeof body.startDate).toBe("string");
expect(typeof body.endDate).toBe("string");
});
});
it("generates markdown report and keeps modal open on fetch error", async () => {
it("shows a localized fetch error and keeps the modal open when preview generation fails", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
@@ -390,9 +417,35 @@ describe("ReportModal", () => {
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
expect(authFetchMock).toHaveBeenCalledWith(
"/api/medications/report-data",
expect.objectContaining({ method: "POST" })
);
});
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.error/i)).toBeInTheDocument();
expect(screen.queryByText(/report\.preview/i)).not.toBeInTheDocument();
});
it("shows a localized error and skips the request when the date range is invalid", async () => {
const onClose = vi.fn();
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
const inputs = screen.getAllByDisplayValue(/\d{2}\.\d{2}\.\d{4}|\d{1,2}\/\d{1,2}\/\d{4}|\d{4}-\d{2}-\d{2}/i);
const startInput = inputs[0] as HTMLInputElement;
const endInput = inputs[1] as HTMLInputElement;
fireEvent.change(startInput.parentElement?.querySelector("input") ?? startInput, {
target: { value: "2026-02-10T10:00" },
});
fireEvent.change(endInput.parentElement?.querySelector("input") ?? endInput, {
target: { value: "2026-02-10T09:00" },
});
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
expect(authFetchMock).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByText(/report\.invalidDateRange/i)).toBeInTheDocument();
});
});
@@ -10,13 +10,21 @@ describe("ShareDialog", () => {
onShareSelectedPersonChange: vi.fn(),
shareSelectedDays: 30,
onShareSelectedDaysChange: vi.fn(),
shareSelectedExpiryDays: null,
onShareSelectedExpiryDaysChange: vi.fn(),
shareAllowJournalNotes: false,
onShareAllowJournalNotesChange: vi.fn(),
shareGenerating: false,
shareLink: null,
onShareLinkChange: vi.fn(),
shareCopied: false,
onShareCopiedChange: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
onClose: vi.fn(),
onGenerateShareLink: vi.fn(),
onRevokeShareLink: vi.fn().mockResolvedValue(true),
onCopyShareLink: vi.fn(),
};
@@ -105,9 +113,13 @@ describe("ShareDialog", () => {
const selects = screen.getAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "Bob" } });
fireEvent.change(selects[1], { target: { value: "90" } });
fireEvent.change(selects[2], { target: { value: "30" } });
fireEvent.click(screen.getByLabelText(/share\.allowJournalNotes/i));
expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob");
expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
expect(defaultProps.onShareSelectedExpiryDaysChange).toHaveBeenCalledWith(30);
expect(defaultProps.onShareAllowJournalNotesChange).toHaveBeenCalledWith(true);
});
it("disables generate button when no person is selected", () => {
@@ -116,4 +128,58 @@ describe("ShareDialog", () => {
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
expect(generateButton).toBeDisabled();
});
it("keeps active share management collapsed until opened", () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
expect(screen.getByText(/share\.manageLinksSummary/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /share\.revoke/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
expect(screen.getByRole("button", { name: /share\.revoke/i })).toBeInTheDocument();
});
it("uses an in-app confirm modal before revoking an active share link", async () => {
render(
<ShareDialog
{...defaultProps}
activeShareLinks={[
{
token: "abcdef0123456789",
takenBy: "Alice",
scheduleDays: 30,
createdAt: "2026-05-17T12:00:00.000Z",
expiresAt: null,
allowJournalNotes: true,
shareUrl: "/share/abcdef0123456789",
},
]}
/>
);
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
fireEvent.click(screen.getByRole("button", { name: /share\.revoke/i }));
expect(screen.getByText(/share\.revokeConfirm/i)).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /share\.revoke/i })[1]);
expect(defaultProps.onRevokeShareLink).toHaveBeenCalledWith("abcdef0123456789");
});
});
@@ -141,6 +141,7 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
sharedBy: "Owner",
takenBy: "Max",
scheduleDays: 30,
allowJournalNotes: false,
automaticDoseId: `1-0-${dateOnlyMs}`,
medications: [
{
@@ -171,17 +172,24 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
function createSharedDoseFetchMock(options: {
token?: string;
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
initialDoses?: Array<{
doseId: string;
skipped?: boolean;
dismissed?: boolean;
takenSource?: string;
hasJournalNote?: boolean;
}>;
}) {
const token = options.token ?? "token-123";
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
const journalState = new Map<string, { note: string | null; createdAt: string | null; updatedAt: string | null }>();
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const body =
typeof init?.body === "string" && init.body.length > 0
? (JSON.parse(init.body) as { doseId: string })
? (JSON.parse(init.body) as { doseId?: string; note?: string | null })
: undefined;
requests.push({ url, method, body });
@@ -190,7 +198,11 @@ function createSharedDoseFetchMock(options: {
}
if (url === `/api/share/${token}/doses` && method === "GET") {
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
const doses = Array.from(doseState.values()).map((dose) => ({
...dose,
hasJournalNote: dose.hasJournalNote === true || Boolean(journalState.get(dose.doseId)?.note?.trim()),
}));
return { ok: true, json: async () => ({ doses }) };
}
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
@@ -203,6 +215,61 @@ function createSharedDoseFetchMock(options: {
return { ok: true, json: async () => ({}) };
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "GET") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const journal = journalState.get(doseId) ?? { note: null, createdAt: null, updatedAt: null };
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: journal.note,
createdAt: journal.createdAt,
updatedAt: journal.updatedAt,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "PUT") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
const timestamp = new Date().toISOString();
journalState.set(doseId, { note: body?.note ?? null, createdAt: timestamp, updatedAt: timestamp });
return {
ok: true,
json: async () => ({
entry: {
doseTrackingId: 1,
doseId,
medicationId: 1,
medicationName: "Ibuprofen",
scheduledFor: new Date().toISOString(),
takenAt: new Date().toISOString(),
dismissed: false,
takenSource: "manual",
markedBy: "Max",
note: body?.note ?? null,
createdAt: timestamp,
updatedAt: timestamp,
},
}),
};
}
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
journalState.delete(doseId);
return { ok: true, json: async () => ({ success: true }) };
}
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
doseState.delete(doseId);
@@ -244,10 +311,109 @@ describe("SharedSchedule", () => {
await waitFor(() => {
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
expect(screen.getByText("share.publicAccessHelp")).toBeInTheDocument();
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
});
});
it("opens and saves a shared journal note when the share link allows notes", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock, requests } = createSharedDoseFetchMock({
sharedData,
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
});
const unavailableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(unavailableJournalButton).toBeDisabled();
expect(unavailableJournalButton).not.toHaveClass("has-note");
expect(unavailableJournalButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
fireEvent.click(screen.getByText("dose.take"));
await waitFor(() => {
expect(requests).toContainEqual({
url: "/api/share/token-123/doses",
method: "POST",
body: { doseId: sharedData.automaticDoseId },
});
expect(document.querySelector(".day-block.today")).not.toHaveClass("collapsed");
});
await waitFor(() => {
const availableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(availableJournalButton).not.toBeDisabled();
expect(availableJournalButton).not.toHaveClass("has-note");
expect(availableJournalButton.closest("span")).not.toHaveAttribute("data-tooltip");
});
fireEvent.click(document.querySelector(".dose-btn.journal") as Element);
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "GET",
body: undefined,
});
});
await waitFor(() => {
expect(screen.getByLabelText("journal.editor.noteLabel")).toHaveValue("");
});
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), { target: { value: "Shared note" } });
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => {
expect(requests).toContainEqual({
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
method: "PUT",
body: { note: "Shared note" },
});
});
await waitFor(() => {
expect(screen.queryByLabelText("journal.editor.noteLabel")).not.toBeInTheDocument();
const savedJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(savedJournalButton).toHaveClass("has-note");
});
});
it("marks shared journal notes from the shared dose read state", async () => {
const referenceNow = new Date();
referenceNow.setHours(12, 0, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
const sharedData = {
...createSharedDataWithTodayDose(referenceNow),
allowJournalNotes: true,
};
const { fetchMock } = createSharedDoseFetchMock({
sharedData,
initialDoses: [{ doseId: sharedData.automaticDoseId, takenSource: "manual", hasJournalNote: true }],
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
renderSharedSchedule("/share/token-123");
await waitFor(() => {
const journalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
expect(journalButton).not.toBeDisabled();
expect(journalButton).toHaveClass("has-note");
});
});
it("renders not found state for missing share link", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url === "/api/share/token-123/doses") {
@@ -275,7 +275,7 @@ describe("UserFilterModal", () => {
const meds: Medication[] = [
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["John", "Jane"] },
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["john", "Jane"] },
];
render(
+111 -22
View File
@@ -4,10 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppProvider, useAppContext } from "../../context/AppContext";
import type { Medication } from "../../types";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
const mockUseAuth = vi.fn();
const mockUseMedications = vi.fn();
const mockUseSettings = vi.fn();
const mockUseDoses = vi.fn();
const mockUseIntakeJournal = vi.fn();
const mockUseCollapsedDays = vi.fn();
const mockUseShare = vi.fn();
const mockUseRefill = vi.fn();
@@ -26,10 +29,19 @@ vi.mock("../../components/Auth", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
vi.mock("../../hooks", () => ({
useMedications: () => mockUseMedications(),
useSettings: () => mockUseSettings(),
useDoses: () => mockUseDoses(),
useIntakeJournal: () => mockUseIntakeJournal(),
useCollapsedDays: () => mockUseCollapsedDays(),
useShare: () => mockUseShare(),
useRefill: () => mockUseRefill(),
@@ -55,7 +67,7 @@ const meds: Medication[] = [
{
id: 11,
name: "Aspirin",
takenBy: ["Max", "Anna"],
takenBy: ["Max", "Anna", "max"],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
@@ -80,7 +92,8 @@ describe("useAppContext", () => {
const loadSettings = vi.fn();
const loadTakenDoses = vi.fn();
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" } });
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" }, authFetch: authFetchMock });
mockUseMedications.mockReturnValue({
meds,
@@ -206,6 +219,35 @@ describe("useAppContext", () => {
loadTakenDoses,
});
mockUseIntakeJournal.mockReturnValue({
journalEditorOpen: false,
journalHistoryOpen: false,
journalTargetDoseId: null,
journalEvent: null,
journalEventLoading: false,
journalEventSaving: false,
journalEventDeleting: false,
journalEventError: null,
journalHistoryEntries: [],
journalHistoryFilters: {
medicationId: null,
from: "",
until: "",
},
journalHistoryLoading: false,
journalHistoryError: null,
openJournalEditor: vi.fn(),
closeJournalEditor: vi.fn(),
saveJournalNote: vi.fn(async () => true),
deleteJournalNote: vi.fn(async () => true),
openJournalHistory: vi.fn(),
closeJournalHistory: vi.fn(),
setJournalHistoryFilters: vi.fn(),
reloadJournalHistory: vi.fn(async () => {}),
reopenJournalHistoryEntry: vi.fn(async () => {}),
resetJournalState: vi.fn(),
});
mockUseCollapsedDays.mockReturnValue({
manuallyCollapsedDays: new Set<string>(),
manuallyExpandedDays: new Set<string>(),
@@ -219,11 +261,19 @@ describe("useAppContext", () => {
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 30,
setShareSelectedDays: vi.fn(),
shareSelectedExpiryDays: null,
setShareSelectedExpiryDays: vi.fn(),
shareAllowJournalNotes: false,
setShareAllowJournalNotes: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
activeShareLinks: [],
activeSharesLoading: false,
revokingShareToken: null,
revokeShareLink: vi.fn(),
openShareDialog: vi.fn(),
generateShareLink: vi.fn(),
copyShareLink: vi.fn(),
@@ -345,7 +395,7 @@ describe("useAppContext", () => {
const clearRefillStateBefore = mockUseRefill().clearRefillState.mock.calls.length;
const resetShareDialogStateBefore = mockUseShare().resetShareDialogState.mock.calls.length;
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" } });
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" }, authFetch: authFetchMock });
rerender();
await waitFor(() => {
@@ -407,11 +457,10 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(fetch).toHaveBeenCalledWith(
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
@@ -447,9 +496,7 @@ describe("useAppContext", () => {
await result.current.handleExport(true);
});
expect(fetch).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true", {
credentials: "include",
});
expect(authFetchMock).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true");
expect(createObjectURL).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
expect(appendChild).toHaveBeenCalled();
@@ -458,9 +505,6 @@ describe("useAppContext", () => {
});
it("handles invalid import JSON file", () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -478,10 +522,46 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.invalidFile");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({ message: "exportImport.invalidFile", tone: "error" });
});
it("parses valid import file and opens confirm modal", () => {
it("parses valid import file and opens confirm modal", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
preview: {
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
includeSensitiveData: true,
incoming: {
medications: 0,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
journalEntries: 0,
imageCount: 0,
hasSettings: false,
},
current: {
medications: 1,
doseHistory: 0,
refillHistory: 0,
shareLinks: 0,
hasSettings: true,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: false,
containsImages: false,
containsSensitiveData: true,
},
},
})
),
});
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
@@ -503,11 +583,20 @@ describe("useAppContext", () => {
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith(
"/api/import/preview",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ version: "1", exportedAt: "2026-01-01T00:00:00.000Z", medications: [] }),
})
);
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
});
});
});
@@ -550,9 +639,6 @@ describe("useAppContext", () => {
});
it("shows import error alert when import API returns non-ok response", async () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
@@ -569,6 +655,9 @@ describe("useAppContext", () => {
await result.current.handleImportConfirm();
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "exportImport.importError: Import failed",
tone: "error",
});
});
});
+1
View File
@@ -0,0 +1 @@
declare const global: typeof globalThis;
+35 -4
View File
@@ -2,9 +2,27 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDoses } from "../../hooks/useDoses";
const feedbackMock = vi.hoisted(() => ({ showFeedback: vi.fn() }));
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
vi.mock("../../context/FeedbackContext", () => ({
useFeedback: () => ({
showFeedback: feedbackMock.showFeedback,
dismissFeedback: vi.fn(),
clearFeedback: vi.fn(),
}),
}));
describe("useDoses", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ doses: [] }),
@@ -15,6 +33,19 @@ describe("useDoses", () => {
vi.clearAllMocks();
});
it("loads taken doses through authFetch", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [] }),
});
renderHook(() => useDoses());
await waitFor(() => {
expect(authFetchMock).toHaveBeenCalledWith("/api/doses/taken");
});
});
it("initializes with empty state", () => {
const { result } = renderHook(() => useDoses());
@@ -273,9 +304,6 @@ describe("useDoses", () => {
});
it("shows an out-of-stock alert and reverts the optimistic mark", async () => {
const alertMock = vi.fn();
global.alert = alertMock;
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({
@@ -297,7 +325,10 @@ describe("useDoses", () => {
await waitFor(() => {
expect(result.current.takenDoses.has("blocked-dose")).toBe(false);
});
expect(alertMock).toHaveBeenCalledWith("common.outOfStockTakeBlocked");
expect(feedbackMock.showFeedback).toHaveBeenCalledWith({
message: "common.outOfStockTakeBlocked",
tone: "error",
});
});
it("undoDoseTaken encodes special characters in dose ID", async () => {
@@ -0,0 +1,183 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type IntakeJournalEntry, useIntakeJournal } from "../../hooks/useIntakeJournal";
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
vi.mock("../../components/Auth", () => ({
useAuth: () => ({
authFetch: authFetchMock,
}),
}));
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
return {
doseTrackingId: 1,
doseId: "11-0-1760000000000-Daniel",
medicationId: 11,
medicationName: "Journal Med",
scheduledFor: "2026-02-10T08:00:00.000Z",
takenAt: "2026-02-10T08:05:00.000Z",
dismissed: false,
takenSource: "manual",
markedBy: "Daniel",
note: null,
updatedAt: null,
createdAt: null,
...overrides,
};
}
describe("useIntakeJournal", () => {
beforeEach(() => {
vi.clearAllMocks();
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("loads an event and updates local note state on save and delete", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const initialEntry = buildEntry();
const savedEntry = buildEntry({
note: "Took after breakfast",
createdAt: "2026-02-10T08:06:00.000Z",
updatedAt: "2026-02-10T08:07:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: initialEntry }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: savedEntry }),
})
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor(initialEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`
);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(initialEntry.doseId);
expect(result.current.journalEvent).toEqual(initialEntry);
let saveResult = false;
await act(async () => {
saveResult = await result.current.saveJournalNote("Took after breakfast");
});
expect(saveResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: "Took after breakfast" }),
})
);
expect(result.current.journalEvent?.note).toBe("Took after breakfast");
let deleteResult = false;
await act(async () => {
deleteResult = await result.current.deleteJournalNote();
});
expect(deleteResult).toBe(true);
expect(authFetchMock).toHaveBeenNthCalledWith(
3,
`/api/intake-journal/event/${encodeURIComponent(initialEntry.doseId)}`,
expect.objectContaining({ method: "DELETE" })
);
expect(result.current.journalEvent).toEqual(
expect.objectContaining({
doseId: initialEntry.doseId,
note: null,
createdAt: null,
updatedAt: null,
})
);
});
it("loads filtered history and reopens an entry in the editor", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
const historyEntry = buildEntry({
doseId: "11-0-1760086400000-Daniel",
note: "Evening note",
updatedAt: "2026-02-11T18:30:00.000Z",
createdAt: "2026-02-11T18:20:00.000Z",
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entries: [historyEntry] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ entry: historyEntry }),
});
const { result } = renderHook(() => useIntakeJournal());
act(() => {
result.current.setJournalHistoryFilters({
medicationId: 11,
from: "2026-02-11T00:00:00.000Z",
to: "2026-02-11T23:59:59.000Z",
limit: 25,
});
result.current.openJournalHistory();
});
await waitFor(() => {
expect(result.current.journalHistoryEntries).toEqual([historyEntry]);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
1,
"/api/intake-journal?medicationId=11&from=2026-02-11T00%3A00%3A00.000Z&to=2026-02-11T23%3A59%3A59.000Z&limit=25"
);
await act(async () => {
await result.current.reopenJournalHistoryEntry(historyEntry.doseId);
});
expect(authFetchMock).toHaveBeenNthCalledWith(
2,
`/api/intake-journal/event/${encodeURIComponent(historyEntry.doseId)}`
);
expect(result.current.journalHistoryOpen).toBe(false);
expect(result.current.journalEditorOpen).toBe(true);
expect(result.current.journalTargetDoseId).toBe(historyEntry.doseId);
expect(result.current.journalEvent).toEqual(historyEntry);
});
it("surfaces owner access errors instead of swallowing them", async () => {
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
fetchMock.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Tracked dose event not found for the current owner" }),
});
const { result } = renderHook(() => useIntakeJournal());
await act(async () => {
await result.current.openJournalEditor("99-0-1760000000000-Daniel");
});
expect(result.current.journalEvent).toBeNull();
expect(result.current.journalEventError).toBe("Tracked dose event not found for the current owner");
});
});

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