Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d657558f7 | |||
| 0c28999c89 | |||
| 2296303236 | |||
| 9a2d42b8b9 | |||
| 088a6c1a05 | |||
| 228fd4cd7e | |||
| e346d60f39 | |||
| afb8e5028c | |||
| 9ab077a037 | |||
| 976d7356ec | |||
| 943148fb49 | |||
| 94bd8bd6e8 | |||
| 0cf1c5353e | |||
| 98cf1ce1d2 | |||
| 75c201cab5 | |||
| 74f079d13e | |||
| fd3b770a81 | |||
| 612aa007aa | |||
| 02af93ec55 | |||
| 8f57aa8bc9 | |||
| f42ed87d94 | |||
| 8de54b9065 | |||
| b489e1e117 | |||
| 8c97abd3c9 | |||
| 2eec415af6 | |||
| 243a46f960 | |||
| 052751b2ba | |||
| 89d565bc9d | |||
| 08a18fc14a | |||
| e41efdf98b | |||
| cefac8cc4e | |||
| 779870960c | |||
| 871e6066ec | |||
| ff100dfea5 | |||
| 47581ca7ad | |||
| 39e9ebbf28 | |||
| 41b20bb4e6 | |||
| f9c51956d5 | |||
| 543b42b540 | |||
| 36a2f7d537 |
@@ -89,6 +89,29 @@ PR #141: "fix: planner checkbox layout on single line"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## PR Metadata (MANDATORY)
|
||||||
|
|
||||||
|
Every Pull Request MUST have the following sidebar fields populated at creation time:
|
||||||
|
|
||||||
|
| Field | Value | How |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
|
||||||
|
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
|
||||||
|
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
|
||||||
|
|
||||||
|
**Label mapping for PRs:**
|
||||||
|
| Branch prefix / commit type | Label |
|
||||||
|
|---|---|
|
||||||
|
| `feat/` | `enhancement` |
|
||||||
|
| `fix/` | `bug` |
|
||||||
|
| `docs/` | `documentation` |
|
||||||
|
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
|
||||||
|
| `chore/release-*` | No label needed (release PRs are automated) |
|
||||||
|
|
||||||
|
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Task 1: Branch, PR, and Merge Workflow
|
## Task 1: Branch, PR, and Merge Workflow
|
||||||
|
|
||||||
When code changes (features or bug fixes) are complete:
|
When code changes (features or bug fixes) are complete:
|
||||||
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
|
|||||||
```bash
|
```bash
|
||||||
git push -u origin feat/short-description
|
git push -u origin feat/short-description
|
||||||
```
|
```
|
||||||
2. Create a Pull Request via GitHub CLI, linking the related issue:
|
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||||
```bash
|
```bash
|
||||||
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
|
gh pr create \
|
||||||
|
--title "fix: short description" \
|
||||||
|
--body "Closes #<ISSUE_NUMBER>
|
||||||
|
|
||||||
Description of changes"
|
Description of changes" \
|
||||||
|
--assignee DanielVolz \
|
||||||
|
--label bug \
|
||||||
|
--project "@DanielVolz's MedAssist-ng project"
|
||||||
```
|
```
|
||||||
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
|
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||||
|
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||||
|
- The `--project` flag links the PR to the Project board.
|
||||||
3. **Present the PR URL to the user and wait for confirmation.**
|
3. **Present the PR URL to the user and wait for confirmation.**
|
||||||
|
|
||||||
### Step 4: Wait for CI and Merge
|
### Step 4: Wait for CI and Merge
|
||||||
@@ -462,7 +492,7 @@ Code complete & validated by testing-manager
|
|||||||
↓
|
↓
|
||||||
1. Ensure a GitHub issue exists (create if not)
|
1. Ensure a GitHub issue exists (create if not)
|
||||||
2. Create feature branch (fix/... or feat/...)
|
2. Create feature branch (fix/... or feat/...)
|
||||||
3. Commit, push, create PR (with "Closes #N" in body)
|
3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
|
||||||
4. Wait for CI (all required checks)
|
4. Wait for CI (all required checks)
|
||||||
5. Merge PR to main (squash + delete branch)
|
5. Merge PR to main (squash + delete branch)
|
||||||
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
|||||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
||||||
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
||||||
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
||||||
|
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
||||||
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
||||||
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
||||||
|
|
||||||
@@ -67,8 +68,8 @@ cd frontend && npm run build
|
|||||||
```bash
|
```bash
|
||||||
cd frontend && npm run test:e2e
|
cd frontend && npm run test:e2e
|
||||||
cd frontend && npm run test:e2e -- --project=chromium
|
cd frontend && npm run test:e2e -- --project=chromium
|
||||||
cd frontend && npm run test:e2e:ui
|
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||||
cd frontend && npm run test:e2e:headed
|
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backend Test Patterns
|
## Backend Test Patterns
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Use this orientation for quick navigation before applying the rules below.
|
|||||||
- Testing work belongs to `@testing-manager`.
|
- Testing work belongs to `@testing-manager`.
|
||||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
- PR/release/CI orchestration belongs to `@release-manager`.
|
||||||
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
||||||
|
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
|
||||||
- Remove obsolete code when re-implementing — never leave dead code behind.
|
- Remove obsolete code when re-implementing — never leave dead code behind.
|
||||||
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||||
steps:
|
steps:
|
||||||
- name: Move project item to Done
|
- name: Move project item to Done
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"vitest.root": "backend",
|
"vitest.root": "backend",
|
||||||
"vitest.enable": true,
|
"vitest.enable": true,
|
||||||
"vitest.commandLine": "npm test --"
|
"vitest.commandLine": "npm test --",
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"test": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+49
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "E2E stable",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E stable + merged video",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:with-video"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E all browsers",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:all"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "E2E all browsers + merged video",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": ["run", "test:e2e:all:with-video"],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
|
},
|
||||||
|
"group": "test",
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -123,6 +123,7 @@ Share your medication schedule with others via a public link.
|
|||||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||||
- Display remaining days of supply
|
- Display remaining days of supply
|
||||||
- Automatic calculation based on intake schedule
|
- Automatic calculation based on intake schedule
|
||||||
|
- Manual stock correction supports partial blisters and loose pills
|
||||||
|
|
||||||
### Medication Refill
|
### Medication Refill
|
||||||
- One-click refill with pack or loose pill options
|
- One-click refill with pack or loose pill options
|
||||||
@@ -132,6 +133,7 @@ Share your medication schedule with others via a public link.
|
|||||||
### Flexible Schedules
|
### Flexible Schedules
|
||||||
- Daily, weekly, or custom intervals per medication
|
- Daily, weekly, or custom intervals per medication
|
||||||
- Independent schedules for each medication
|
- Independent schedules for each medication
|
||||||
|
- Optional timeline filters for dashboard and shared schedule views
|
||||||
|
|
||||||
### Stock Alerts & Reminders
|
### Stock Alerts & Reminders
|
||||||
- Notifications before stock runs out
|
- Notifications before stock runs out
|
||||||
@@ -143,6 +145,10 @@ Share your medication schedule with others via a public link.
|
|||||||
- Plan ahead for vacations, business trips, or hospital stays
|
- Plan ahead for vacations, business trips, or hospital stays
|
||||||
- Send demand reports via email or push notification
|
- Send demand reports via email or push notification
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
- Generate medication reports as PDF, Markdown, or plain text
|
||||||
|
- Include intake history, refill history, and prescription details
|
||||||
|
|
||||||
### Multi-Person Support
|
### Multi-Person Support
|
||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1771164000000,
|
"when": 1771164000000,
|
||||||
"tag": "0009_add_medication_start_date",
|
"tag": "0009_add_medication_start_date",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771694832866,
|
||||||
|
"tag": "0010_mean_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+49
-1419
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.11.1",
|
"version": "1.14.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.1",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"@types/nodemailer": "^6.4.21",
|
"@types/nodemailer": "^7.0.10",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { existsSync, statSync } from "node:fs";
|
import { existsSync, statSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { type Client, createClient } from "@libsql/client";
|
import { type Client, createClient } from "@libsql/client";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
|
|||||||
import {
|
import {
|
||||||
ensureDataDirectory,
|
ensureDataDirectory,
|
||||||
ensureDefaultUser,
|
ensureDefaultUser,
|
||||||
getDataDir,
|
|
||||||
getDbPaths,
|
getDbPaths,
|
||||||
repairOrphanedDoseIds,
|
repairOrphanedDoseIds,
|
||||||
repairTrailingHyphenDoseIds,
|
repairTrailingHyphenDoseIds,
|
||||||
@@ -65,8 +63,8 @@ let client: Client;
|
|||||||
try {
|
try {
|
||||||
client = createClient({ url });
|
client = createClient({ url });
|
||||||
log.debug(`[DB] Database client created successfully`);
|
log.debug(`[DB] Database client created successfully`);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
|
||||||
log.error(`[DB] Database path: ${dbPath}`);
|
log.error(`[DB] Database path: ${dbPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-23
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
|||||||
writeFileSync(testFile, "test");
|
writeFileSync(testFile, "test");
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
return { success: false, error: err.message };
|
return { success: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
|
|||||||
try {
|
try {
|
||||||
await migrate(database, { migrationsFolder });
|
await migrate(database, { migrationsFolder });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// If the error is about existing schema objects, the DB is already up-to-date
|
// If the error is about existing schema objects, the DB is already up-to-date
|
||||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
// This happens when ALTER migrations in client.ts have already added the columns,
|
||||||
// or when tables were created before drizzle migrations were introduced
|
// or when tables were created before drizzle migrations were introduced
|
||||||
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
|
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
||||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
||||||
}
|
}
|
||||||
return { success: false, error: err.message };
|
return { success: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +111,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for intake automation auditability (manual vs automatic taken)
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
@@ -140,6 +142,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
// Added for share stock visibility toggle
|
// Added for share stock visibility toggle
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
|
// Added for timeline visibility toggles (dashboard + shared schedule)
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||||
// Added for prescription refill tracking and reminders
|
// Added for prescription refill tracking and reminders
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||||
@@ -158,10 +164,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "duplicate column" errors - column already exists
|
// Silently ignore "duplicate column" errors - column already exists
|
||||||
if (!e.message?.includes("duplicate column")) {
|
if (!(e as Error).message?.includes("duplicate column")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,10 +188,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of createTableMigrations) {
|
for (const sql of createTableMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "table already exists" errors
|
// Silently ignore "table already exists" errors
|
||||||
if (!e.message?.includes("already exists")) {
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,10 +205,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
for (const sql of createIndexMigrations) {
|
for (const sql of createIndexMigrations) {
|
||||||
try {
|
try {
|
||||||
await client.execute(sql);
|
await client.execute(sql);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
// Silently ignore "already exists" errors
|
// Silently ignore "already exists" errors
|
||||||
if (!e.message?.includes("already exists")) {
|
if (!(e as Error).message?.includes("already exists")) {
|
||||||
errors.push(e.message);
|
errors.push((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,8 +233,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
|
|||||||
return true; // Created
|
return true; // Created
|
||||||
}
|
}
|
||||||
return false; // Already exists
|
return false; // Already exists
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error(`[DB] Error creating default user:`, e.message);
|
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +261,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
|
|||||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||||
);
|
);
|
||||||
repaired = result.rowsAffected;
|
repaired = result.rowsAffected;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
|
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repaired, errors };
|
return { repaired, errors };
|
||||||
@@ -379,14 +385,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
|||||||
args: [newDoseId, dose.id],
|
args: [newDoseId, dose.id],
|
||||||
});
|
});
|
||||||
repaired++;
|
repaired++;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`Repair failed: ${e.message}`);
|
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repaired, errors };
|
return { repaired, errors };
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export async function executeMigration(
|
|||||||
const executed = Number(tables.rows[0].count) || 0;
|
const executed = Number(tables.rows[0].count) || 0;
|
||||||
|
|
||||||
return { success: true, executed, errors };
|
return { success: true, executed, errors };
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
errors.push(err.message);
|
errors.push((err as Error).message);
|
||||||
return { success: false, executed: 0, errors };
|
return { success: false, executed: 0, errors };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] {
|
|||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
|
last_reminder_med_name text,
|
||||||
|
last_reminder_taken_by text,
|
||||||
|
last_stock_reminder_sent text,
|
||||||
|
last_stock_reminder_channel text,
|
||||||
|
last_stock_reminder_med_names text,
|
||||||
|
last_prescription_reminder_sent text,
|
||||||
|
last_prescription_reminder_channel text,
|
||||||
|
last_prescription_reminder_med_names text,
|
||||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// UI timeline visibility preferences
|
||||||
|
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
|
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||||
|
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
|
||||||
// Last notification tracking (intake reminders)
|
// Last notification tracking (intake reminders)
|
||||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||||
lastNotificationType: text("last_notification_type"),
|
lastNotificationType: text("last_notification_type"),
|
||||||
@@ -159,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { medicationRoutes } from "./routes/medications.js";
|
|||||||
import { oidcRoutes } from "./routes/oidc.js";
|
import { oidcRoutes } from "./routes/oidc.js";
|
||||||
import { plannerRoutes } from "./routes/planner.js";
|
import { plannerRoutes } from "./routes/planner.js";
|
||||||
import { refillRoutes } from "./routes/refills.js";
|
import { refillRoutes } from "./routes/refills.js";
|
||||||
|
import { reportRoutes } from "./routes/report.js";
|
||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
import { shareRoutes } from "./routes/share.js";
|
import { shareRoutes } from "./routes/share.js";
|
||||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||||
@@ -118,6 +119,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -190,6 +192,7 @@ await app.register(shareRoutes);
|
|||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// Re-throw our own errors
|
// Re-throw our own errors
|
||||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
|
||||||
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
// JWT verification failed
|
// JWT verification failed
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -94,6 +95,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: null, // Marked by the user themselves
|
markedBy: null, // Marked by the user themselves
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -227,6 +229,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -270,6 +273,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId: share.userId,
|
userId: share.userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: share.takenBy, // e.g. "Daniel"
|
markedBy: share.takenBy, // e.g. "Daniel"
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { extname, resolve } from "node:path";
|
import { extname, resolve } from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.0";
|
const EXPORT_VERSION = "1.1";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
|
|||||||
packCount: z.number().int().min(0).default(1),
|
packCount: z.number().int().min(0).default(1),
|
||||||
blistersPerPack: z.number().int().min(1).default(1),
|
blistersPerPack: z.number().int().min(1).default(1),
|
||||||
pillsPerBlister: z.number().int().min(1).default(1),
|
pillsPerBlister: z.number().int().min(1).default(1),
|
||||||
|
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
||||||
@@ -60,6 +61,7 @@ const medicationExportSchema = z.object({
|
|||||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||||
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
||||||
prescriptionExpiryDate: z.string().nullable().optional(),
|
prescriptionExpiryDate: z.string().nullable().optional(),
|
||||||
|
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
|
||||||
image: z.string().nullable().optional(), // base64 data URL or null
|
image: z.string().nullable().optional(), // base64 data URL or null
|
||||||
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||||
});
|
});
|
||||||
@@ -70,10 +72,19 @@ const doseHistorySchema = z.object({
|
|||||||
scheduledTime: z.string(), // ISO datetime
|
scheduledTime: z.string(), // ISO datetime
|
||||||
takenAt: z.string(), // ISO datetime
|
takenAt: z.string(), // ISO datetime
|
||||||
markedBy: z.string().nullable().optional(),
|
markedBy: z.string().nullable().optional(),
|
||||||
|
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||||
dismissed: z.boolean().default(false),
|
dismissed: z.boolean().default(false),
|
||||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refillHistoryExportSchema = z.object({
|
||||||
|
medicationRef: z.string(), // References _exportId
|
||||||
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
usedPrescription: z.boolean().default(false),
|
||||||
|
refillDate: z.string(), // ISO datetime
|
||||||
|
});
|
||||||
|
|
||||||
const shareLinkSchema = z.object({
|
const shareLinkSchema = z.object({
|
||||||
takenBy: z.string().min(1),
|
takenBy: z.string().min(1),
|
||||||
scheduleDays: z.number().int().min(1).default(30),
|
scheduleDays: z.number().int().min(1).default(30),
|
||||||
@@ -106,9 +117,11 @@ const settingsExportSchema = z
|
|||||||
lowStockDays: z.number().int().default(30),
|
lowStockDays: z.number().int().default(30),
|
||||||
normalStockDays: z.number().int().default(90),
|
normalStockDays: z.number().int().default(90),
|
||||||
highStockDays: z.number().int().default(180),
|
highStockDays: z.number().int().default(180),
|
||||||
|
expiryWarningDays: z.number().int().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: z.string().default("en"),
|
language: z.string().default("en"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
|
shareStockStatus: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -118,6 +131,7 @@ const importDataSchema = z.object({
|
|||||||
includeSensitiveData: z.boolean().default(false),
|
includeSensitiveData: z.boolean().default(false),
|
||||||
medications: z.array(medicationExportSchema).default([]),
|
medications: z.array(medicationExportSchema).default([]),
|
||||||
doseHistory: z.array(doseHistorySchema).default([]),
|
doseHistory: z.array(doseHistorySchema).default([]),
|
||||||
|
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||||
settings: settingsExportSchema,
|
settings: settingsExportSchema,
|
||||||
shareLinks: z.array(shareLinkSchema).default([]),
|
shareLinks: z.array(shareLinkSchema).default([]),
|
||||||
});
|
});
|
||||||
@@ -127,7 +141,7 @@ const importDataSchema = z.object({
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
return getAnonymousUserId();
|
return getAnonymousUserId();
|
||||||
}
|
}
|
||||||
@@ -285,6 +299,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
packCount: med.packCount ?? 1,
|
packCount: med.packCount ?? 1,
|
||||||
blistersPerPack: med.blistersPerPack ?? 1,
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||||
|
totalPills: med.totalPills ?? null,
|
||||||
looseTablets: med.looseTablets ?? 0,
|
looseTablets: med.looseTablets ?? 0,
|
||||||
stockAdjustment: med.stockAdjustment ?? 0,
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
packageType: med.packageType ?? "blister",
|
packageType: med.packageType ?? "blister",
|
||||||
@@ -303,6 +318,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||||
|
dismissedUntil: med.dismissedUntil ?? null,
|
||||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||||
};
|
};
|
||||||
@@ -349,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
scheduledTime: scheduledTimeIso,
|
scheduledTime: scheduledTimeIso,
|
||||||
takenAt: takenAtIso,
|
takenAt: takenAtIso,
|
||||||
markedBy: dose.markedBy,
|
markedBy: dose.markedBy,
|
||||||
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenByPerson: parsed.person,
|
takenByPerson: parsed.person,
|
||||||
};
|
};
|
||||||
@@ -380,8 +397,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
lowStockDays: settings.lowStockDays,
|
lowStockDays: settings.lowStockDays,
|
||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
|
expiryWarningDays: settings.expiryWarningDays,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode,
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
|
shareStockStatus: settings.shareStockStatus,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -412,6 +431,39 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. Load refill history
|
||||||
|
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
|
|
||||||
|
const exportRefillHistory = refills
|
||||||
|
.map((refill) => {
|
||||||
|
const exportId = medIdToExportId.get(refill.medicationId);
|
||||||
|
if (!exportId) return null; // Orphaned refill, skip
|
||||||
|
|
||||||
|
// Safely convert refillDate to ISO string
|
||||||
|
let refillDateIso: string;
|
||||||
|
try {
|
||||||
|
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||||
|
refillDateIso = refill.refillDate.toISOString();
|
||||||
|
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||||
|
const d = new Date(refill.refillDate);
|
||||||
|
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
refillDateIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
refillDateIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationRef: exportId,
|
||||||
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
|
refillDate: refillDateIso,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
// Build export object
|
// Build export object
|
||||||
const exportData = {
|
const exportData = {
|
||||||
version: EXPORT_VERSION,
|
version: EXPORT_VERSION,
|
||||||
@@ -419,12 +471,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
includeSensitiveData: includeSensitive,
|
includeSensitiveData: includeSensitive,
|
||||||
medications: exportMedications,
|
medications: exportMedications,
|
||||||
doseHistory: exportDoseHistory,
|
doseHistory: exportDoseHistory,
|
||||||
|
refillHistory: exportRefillHistory,
|
||||||
settings: exportSettings,
|
settings: exportSettings,
|
||||||
shareLinks: exportShareLinks,
|
shareLinks: exportShareLinks,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set download headers
|
// Set download headers
|
||||||
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||||
|
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||||
|
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||||
|
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||||
reply.header("Content-Type", "application/json");
|
reply.header("Content-Type", "application/json");
|
||||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
@@ -475,7 +532,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete in order: doses, share tokens, medications, settings
|
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||||
|
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||||
await db.delete(medications).where(eq(medications.userId, userId));
|
await db.delete(medications).where(eq(medications.userId, userId));
|
||||||
@@ -517,6 +575,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
looseTablets: med.inventory.looseTablets,
|
looseTablets: med.inventory.looseTablets,
|
||||||
|
totalPills: med.inventory.totalPills ?? null,
|
||||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
@@ -536,6 +595,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||||
|
dismissedUntil: med.dismissedUntil || null,
|
||||||
imageUrl: null, // Will be set after image is saved
|
imageUrl: null, // Will be set after image is saved
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -567,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -594,8 +655,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||||
highStockDays: importData.settings.highStockDays ?? 180,
|
highStockDays: importData.settings.highStockDays ?? 180,
|
||||||
|
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||||
language: importData.settings.language ?? "en",
|
language: importData.settings.language ?? "en",
|
||||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||||
|
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,11 +676,27 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Import refill history with remapped medication IDs
|
||||||
|
for (const refill of importData.refillHistory) {
|
||||||
|
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||||
|
if (!newMedId) continue; // Skip orphaned refill records
|
||||||
|
|
||||||
|
await db.insert(refillHistory).values({
|
||||||
|
medicationId: newMedId,
|
||||||
|
userId,
|
||||||
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
|
refillDate: new Date(refill.refillDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imported: {
|
imported: {
|
||||||
medications: importData.medications.length,
|
medications: importData.medications.length,
|
||||||
doseHistory: importData.doseHistory.length,
|
doseHistory: importData.doseHistory.length,
|
||||||
|
refillHistory: importData.refillHistory.length,
|
||||||
settings: importData.settings ? 1 : 0,
|
settings: importData.settings ? 1 : 0,
|
||||||
shareLinks: importData.shareLinks.length,
|
shareLinks: importData.shareLinks.length,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { doseTracking, medications } from "../db/schema.js";
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -623,9 +623,9 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
||||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
|
||||||
"/medications/:id/stock-adjustment",
|
"/medications/:id/stock-adjustment",
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
@@ -640,16 +640,32 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
if (!existing) return reply.notFound();
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
||||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
if (
|
||||||
|
looseTablets !== undefined &&
|
||||||
|
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||||
|
) {
|
||||||
|
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: {
|
||||||
|
stockAdjustment: number;
|
||||||
|
lastStockCorrectionAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
looseTablets?: number;
|
||||||
|
} = {
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (looseTablets !== undefined) {
|
||||||
|
updateFields.looseTablets = looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(medications)
|
.update(medications)
|
||||||
.set({
|
.set(updateFields)
|
||||||
stockAdjustment,
|
|
||||||
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -776,26 +792,38 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const [settingsRow] = await db
|
||||||
|
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||||
|
.from(userSettings)
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||||
|
|
||||||
// Get all taken doses for this user to calculate actual consumption
|
// Get all taken doses for this user to calculate actual consumption
|
||||||
const takenDoses = await db
|
const takenDoses = await db
|
||||||
.select()
|
.select()
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
// Create a map of medication ID to taken dose count
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
takenDoses.forEach((dose) => {
|
takenDoses.forEach((dose) => {
|
||||||
const parts = dose.doseId.split("-");
|
const parts = dose.doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length < 3) return;
|
||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
if (Number.isNaN(medId)) return;
|
||||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
|
||||||
if (!takenDosesMap.has(medId)) {
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
takenDosesMap.set(medId, []);
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
}
|
|
||||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
|
||||||
}
|
}
|
||||||
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
let takenAtMs: number;
|
||||||
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
|
} else {
|
||||||
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
}
|
}
|
||||||
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use current time as the reference point for "available" stock
|
// Use current time as the reference point for "available" stock
|
||||||
@@ -822,69 +850,109 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
? looseTablets + stockAdjustment
|
? looseTablets + stockAdjustment
|
||||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
|
||||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
|
||||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
// Build a Set of taken dose IDs for quick lookup
|
|
||||||
const takenDoseIds = new Set(
|
|
||||||
takenDoses
|
|
||||||
.filter((dose) => {
|
|
||||||
const parts = dose.doseId.split("-");
|
|
||||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
|
||||||
})
|
|
||||||
.map((dose) => dose.doseId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count consumed pills by generating expected doses and checking if they're taken
|
// Count consumed pills by generating expected doses and checking if they're taken
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
const msPerDay = 86400000;
|
const msPerDay = 86400000;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
blisters.forEach((blister, blisterIdx) => {
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
const blisterStart = parseLocalDateTime(blister.start);
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
if (Number.isNaN(blisterStart.getTime())) return;
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
const period = Math.max(1, blister.every) * msPerDay;
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
// After a stock correction, start counting from the NEXT scheduled
|
|
||||||
// dose, because the user's pill count already reflects all
|
|
||||||
// consumption up to the correction time.
|
|
||||||
let effectiveStart: number;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
effectiveStart = stockCorrectionCutoff + period;
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
} else {
|
} else {
|
||||||
effectiveStart = blisterStart.getTime();
|
effectiveStart = blisterStart;
|
||||||
}
|
}
|
||||||
if (effectiveStart > now.getTime()) return;
|
|
||||||
|
|
||||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
|
||||||
|
|
||||||
// Get the people for this intake (from intakes array or medication takenBy)
|
|
||||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
|
||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const peopleForThisIntake: (string | null)[] = intakePerson
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
? [intakePerson]
|
let peopleForThisIntake: Array<string | null>;
|
||||||
: takenByJson.length > 0
|
if (intakePerson) {
|
||||||
? takenByJson
|
peopleForThisIntake = [intakePerson];
|
||||||
: [null];
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
// Generate expected dose IDs and check if they're taken
|
let timeBasedConsumed = 0;
|
||||||
for (let i = 0; i < occurrences; i++) {
|
let lastAutoConsumedDateMs = 0;
|
||||||
const doseDate = new Date(effectiveStart + i * period);
|
|
||||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
|
||||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
|
||||||
|
|
||||||
// Check if each person has taken this dose
|
if (effectiveStart <= now.getTime()) {
|
||||||
for (const person of peopleForThisIntake) {
|
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
if (takenDoseIds.has(doseId)) {
|
|
||||||
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
|
lastAutoConsumedDateMs = new Date(
|
||||||
|
lastDoseTime.getFullYear(),
|
||||||
|
lastDoseTime.getMonth(),
|
||||||
|
lastDoseTime.getDate()
|
||||||
|
).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start);
|
||||||
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStart.getFullYear(),
|
||||||
|
blisterStart.getMonth(),
|
||||||
|
blisterStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
|
|
||||||
|
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
consumedUntilNow += blister.usage;
|
consumedUntilNow += blister.usage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||||
|
|
||||||
@@ -930,6 +998,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationId: row.id,
|
medicationId: row.id,
|
||||||
medicationName: row.name,
|
medicationName: row.name,
|
||||||
totalPills: currentStock,
|
totalPills: currentStock,
|
||||||
|
currentPills: currentStock,
|
||||||
plannerUsage: usageTotal,
|
plannerUsage: usageTotal,
|
||||||
blisterSize: pillsPerBlister,
|
blisterSize: pillsPerBlister,
|
||||||
blistersNeeded,
|
blistersNeeded,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return reply.redirect(authUrl.href);
|
return reply.redirect(authUrl.href);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error("[OIDC] Login error:", err);
|
console.error("[OIDC] Login error:", err);
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// Extract username from configured claim
|
// Extract username from configured claim
|
||||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||||
const username =
|
const username =
|
||||||
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
(userInfo as Record<string, string>)[usernameClaim] ||
|
||||||
|
userInfo.preferred_username ||
|
||||||
|
userInfo.email ||
|
||||||
|
userInfo.sub;
|
||||||
const oidcSubject = userInfo.sub;
|
const oidcSubject = userInfo.sub;
|
||||||
|
|
||||||
if (!username || !oidcSubject) {
|
if (!username || !oidcSubject) {
|
||||||
@@ -210,7 +213,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
|||||||
// In dev: CORS_ORIGINS contains the frontend URL
|
// In dev: CORS_ORIGINS contains the frontend URL
|
||||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error("[OIDC] Callback error:", err);
|
console.error("[OIDC] Callback error:", err);
|
||||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,8 +509,10 @@ ${getFooterPlain(language)}`;
|
|||||||
const buildTableRow = (row: LowStockItem) => {
|
const buildTableRow = (row: LowStockItem) => {
|
||||||
const isEmpty = row.medsLeft <= 0;
|
const isEmpty = row.medsLeft <= 0;
|
||||||
const isCritical = row.isCritical !== false;
|
const isCritical = row.isCritical !== false;
|
||||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||||
|
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
const safeName = escapeHtml(row.name);
|
const safeName = escapeHtml(row.name);
|
||||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||||
@@ -586,7 +588,7 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
// Send push notification if enabled
|
// Send push notification if enabled
|
||||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
||||||
@@ -603,7 +605,8 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
// Update the reminder state to record this notification was sent
|
// Update the reminder state to record this notification was sent
|
||||||
if (results.email || results.push) {
|
if (results.email || results.push) {
|
||||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
const singleChannel = results.email ? "email" : "push";
|
||||||
|
const channel = results.email && results.push ? "both" : singleChannel;
|
||||||
updateReminderSentTime("stock", channel);
|
updateReminderSentTime("stock", channel);
|
||||||
|
|
||||||
// Also update user settings in database so frontend can display the info
|
// Also update user settings in database so frontend can display the info
|
||||||
@@ -700,14 +703,15 @@ ${getFooterPlain(language)}`;
|
|||||||
|
|
||||||
const bodyText =
|
const bodyText =
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||||
const alertText =
|
const emptyAlert =
|
||||||
emptyRx.length > 0
|
emptyRx.length === 1
|
||||||
? emptyRx.length === 1
|
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
: lowRx.length === 1
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
const tableRows = filteredPrescriptionLow
|
const tableRows = filteredPrescriptionLow
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -807,7 +811,7 @@ ${getFooterPlain(language)}`;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
||||||
@@ -823,7 +827,8 @@ ${getFooterPlain(language)}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (results.email || results.push) {
|
if (results.email || results.push) {
|
||||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
const singleChannel = results.email ? "email" : "push";
|
||||||
|
const channel = results.email && results.push ? "both" : singleChannel;
|
||||||
updateReminderSentTime("prescription", channel);
|
updateReminderSentTime("prescription", channel);
|
||||||
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,23 +52,37 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
if (!med) return reply.notFound("Medication not found");
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||||
|
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||||
|
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
||||||
|
const effectiveLoosePillsAdded = loosePillsAdded;
|
||||||
|
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||||
|
|
||||||
|
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||||
|
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||||
|
}
|
||||||
|
|
||||||
if (usePrescription) {
|
if (usePrescription) {
|
||||||
if (!(med.prescriptionEnabled ?? false)) {
|
if (!(med.prescriptionEnabled ?? false)) {
|
||||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||||
}
|
}
|
||||||
const remaining = med.prescriptionRemainingRefills ?? 0;
|
if (remainingPrescriptionRefills <= 0) {
|
||||||
if (remaining <= 0) {
|
|
||||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||||
}
|
}
|
||||||
|
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||||
|
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update medication stock
|
// Update medication stock
|
||||||
const newPackCount = med.packCount + packsAdded;
|
const newPackCount = med.packCount + effectivePacksAdded;
|
||||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
|
let consumedRefills = 0;
|
||||||
|
if (usePrescription) {
|
||||||
|
consumedRefills = isBottle ? 1 : effectivePacksAdded;
|
||||||
|
}
|
||||||
const newRemainingRefills = usePrescription
|
const newRemainingRefills = usePrescription
|
||||||
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -77,8 +91,6 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
stockAdjustment: 0, // Reset offset since we're adding to base stock
|
|
||||||
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
@@ -89,16 +101,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
.values({
|
.values({
|
||||||
medicationId: medId,
|
medicationId: medId,
|
||||||
userId,
|
userId,
|
||||||
packsAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
usedPrescription: usePrescription,
|
usedPrescription: usePrescription,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Calculate pills added for response (packageType-aware)
|
// Calculate pills added for response (packageType-aware)
|
||||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
|
const totalPillsAdded = isBottle
|
||||||
|
? effectiveLoosePillsAdded
|
||||||
|
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||||
const newTotalPills = isBottle
|
const newTotalPills = isBottle
|
||||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
? newLooseTablets + (med.stockAdjustment ?? 0)
|
||||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||||
@@ -107,8 +120,8 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
success: true,
|
success: true,
|
||||||
refill: {
|
refill: {
|
||||||
id: refill.id,
|
id: refill.id,
|
||||||
packsAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
totalPillsAdded,
|
totalPillsAdded,
|
||||||
refillDate: refill.refillDate,
|
refillDate: refill.refillDate,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { doseTracking, medications, refillHistory } from "../db/schema.js";
|
||||||
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
|
||||||
|
const reportDataSchema = z.object({
|
||||||
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function reportRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
|
||||||
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return getAnonymousUserId();
|
||||||
|
}
|
||||||
|
const authUser = request.user as unknown as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
|
||||||
|
throw new Error("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /medications/report-data - Get aggregated dose/refill data for report generation
|
||||||
|
app.post("/medications/report-data", async (req, reply) => {
|
||||||
|
const parsed = reportDataSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
const { medicationIds } = parsed.data;
|
||||||
|
|
||||||
|
// Verify all medications belong to this user
|
||||||
|
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||||
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
|
for (const id of medicationIds) {
|
||||||
|
if (!userMedIds.has(id)) {
|
||||||
|
return reply.status(403).send({ error: "Access denied to medication" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch dose tracking for all requested medications
|
||||||
|
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
|
||||||
|
const allDoses = await db
|
||||||
|
.select({
|
||||||
|
doseId: doseTracking.doseId,
|
||||||
|
takenAt: doseTracking.takenAt,
|
||||||
|
dismissed: doseTracking.dismissed,
|
||||||
|
takenSource: doseTracking.takenSource,
|
||||||
|
})
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
|
// Group doses by medication ID
|
||||||
|
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||||
|
for (const dose of allDoses) {
|
||||||
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
|
dosesByMed.get(medId)!.push({
|
||||||
|
takenAt: dose.takenAt,
|
||||||
|
dismissed: dose.dismissed,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch refill history for requested medications
|
||||||
|
const result: Record<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
|
dosesDismissed: number;
|
||||||
|
firstDoseAt: string | null;
|
||||||
|
lastDoseAt: string | null;
|
||||||
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const medId of medicationIds) {
|
||||||
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
|
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Get refills for this medication
|
||||||
|
const refills = await db.select().from(refillHistory).where(eq(refillHistory.medicationId, medId));
|
||||||
|
|
||||||
|
result[medId] = {
|
||||||
|
dosesTaken: takenDoses.length,
|
||||||
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
|
dosesDismissed: dismissedDoses.length,
|
||||||
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
|
refills: refills.map((r) => ({
|
||||||
|
packsAdded: r.packsAdded,
|
||||||
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
@@ -33,6 +33,9 @@ export type UserSettings = {
|
|||||||
language: Language;
|
language: Language;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
shareStockStatus: boolean;
|
shareStockStatus: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
lastAutoEmailSent: string | null;
|
lastAutoEmailSent: string | null;
|
||||||
lastNotificationType: string | null;
|
lastNotificationType: string | null;
|
||||||
lastNotificationChannel: string | null;
|
lastNotificationChannel: string | null;
|
||||||
@@ -69,6 +72,9 @@ type SettingsBody = {
|
|||||||
language: string;
|
language: string;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
shareStockStatus: boolean;
|
shareStockStatus: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestEmailBody = {
|
type TestEmailBody = {
|
||||||
@@ -119,6 +125,9 @@ function getDefaultSettings() {
|
|||||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||||
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
||||||
|
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
||||||
|
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
||||||
|
swapDashboardMainSections: false,
|
||||||
lastAutoEmailSent: null,
|
lastAutoEmailSent: null,
|
||||||
lastNotificationType: null,
|
lastNotificationType: null,
|
||||||
lastNotificationChannel: null,
|
lastNotificationChannel: null,
|
||||||
@@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
|||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
|||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -239,7 +254,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
// If auth is disabled, use the anonymous user
|
// If auth is disabled, use the anonymous user
|
||||||
if (!env.AUTH_ENABLED) {
|
if (!env.AUTH_ENABLED) {
|
||||||
return getAnonymousUserId();
|
return getAnonymousUserId();
|
||||||
@@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: settings.shareStockStatus ?? true,
|
shareStockStatus: settings.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||||
// SMTP settings (from .env - shared/server-configured)
|
// SMTP settings (from .env - shared/server-configured)
|
||||||
smtpHost: process.env.SMTP_HOST ?? "",
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
||||||
@@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
language: body.language ?? "en",
|
language: body.language ?? "en",
|
||||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: body.shareStockStatus ?? true,
|
shareStockStatus: body.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
|
||||||
|
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -544,7 +565,7 @@ export async function sendShoutrrrNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
const method = "POST";
|
const method = "POST";
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
shareStockStatus: settings?.shareStockStatus ?? true,
|
||||||
|
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||||
|
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
getTimezone,
|
getTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
type Intake,
|
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
parseIntakeReminderState,
|
parseIntakeReminderState,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -51,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
|
const intakeDate = intake.intakeTime;
|
||||||
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
|
if (intake.takenBy) {
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
|
}
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoMarkDueIntakesAsTaken(
|
||||||
|
settings: UserSettings & { userId: number },
|
||||||
|
rows: (typeof medications.$inferSelect)[],
|
||||||
|
locale: string,
|
||||||
|
tz: string,
|
||||||
|
logger: ServiceLogger
|
||||||
|
): Promise<number> {
|
||||||
|
if (settings.stockCalculationMode !== "automatic") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const existingToday = await db
|
||||||
|
.select({ doseId: doseTracking.doseId })
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, settings.userId),
|
||||||
|
gte(doseTracking.takenAt, todayStart),
|
||||||
|
lte(doseTracking.takenAt, todayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const med of rows) {
|
||||||
|
if (med.isObsolete) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
if (intakes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const todaysIntakes = getTodaysIntakes(
|
||||||
|
med.name,
|
||||||
|
intakes,
|
||||||
|
medicationTakenBy,
|
||||||
|
med.pillWeightMg,
|
||||||
|
locale,
|
||||||
|
tz,
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const intake of todaysIntakes) {
|
||||||
|
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doseId = buildDoseIdForIntake({
|
||||||
|
...intake,
|
||||||
|
medicationId: intake.medicationId,
|
||||||
|
blisterIndex: intake.blisterIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDoseIds.has(doseId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: settings.userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: intake.intakeTime,
|
||||||
|
markedBy: null,
|
||||||
|
takenSource: "automatic",
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
existingDoseIds.add(doseId);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inserted > 0) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendIntakeReminderEmail(
|
async function sendIntakeReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
intakes: UpcomingIntake[],
|
intakes: UpcomingIntake[],
|
||||||
@@ -247,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, settings.userId))
|
||||||
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const locale = getDateLocale(language);
|
||||||
|
const tz = getTimezone();
|
||||||
|
|
||||||
|
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||||
|
|
||||||
// Check if any intake reminder notifications are enabled (granular check)
|
// Check if any intake reminder notifications are enabled (granular check)
|
||||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||||
@@ -263,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Get all medications with intake reminders enabled for this user
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(medications)
|
|
||||||
.where(eq(medications.userId, settings.userId))
|
|
||||||
.orderBy(medications.id);
|
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
@@ -281,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
const locale = getDateLocale(language);
|
|
||||||
const tz = getTimezone();
|
|
||||||
|
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
@@ -321,7 +430,7 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
||||||
@@ -684,7 +793,8 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
saveIntakeReminderState(state);
|
saveIntakeReminderState(state);
|
||||||
|
|
||||||
// Update global reminder state for UI display
|
// Update global reminder state for UI display
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
updateReminderSentTime("intake", channel);
|
updateReminderSentTime("intake", channel);
|
||||||
|
|
||||||
// Also update user settings in database so frontend can display the info
|
// Also update user settings in database so frontend can display the info
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { medications, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
parseBlisters,
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
|
parseTakenByJson,
|
||||||
type ReminderState,
|
type ReminderState,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
@@ -38,6 +40,56 @@ function escapeHtml(text: string): string {
|
|||||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||||
|
|
||||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||||
|
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
|
||||||
|
const LOCK_STALE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function ensureReminderLocksDir(): void {
|
||||||
|
if (!existsSync(reminderLocksDir)) {
|
||||||
|
mkdirSync(reminderLocksDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireReminderSendLock(lockKey: string): string | null {
|
||||||
|
ensureReminderLocksDir();
|
||||||
|
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
|
||||||
|
|
||||||
|
const tryCreateLock = (): boolean => {
|
||||||
|
try {
|
||||||
|
const fd = openSync(lockFilePath, "wx");
|
||||||
|
closeSync(fd);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(lockFilePath);
|
||||||
|
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
if (tryCreateLock()) {
|
||||||
|
return lockFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; lock acquisition fails safely
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReminderSendLock(lockFilePath: string | null): void {
|
||||||
|
if (!lockFilePath) return;
|
||||||
|
try {
|
||||||
|
unlinkSync(lockFilePath);
|
||||||
|
} catch {
|
||||||
|
// ignore release errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadReminderState(): ReminderState {
|
function loadReminderState(): ReminderState {
|
||||||
try {
|
try {
|
||||||
@@ -119,10 +171,6 @@ export async function updateUserReminderSentTime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
|
||||||
return parseBlisters(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
@@ -142,7 +190,8 @@ async function getMedicationsNeedingReminder(
|
|||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
language: Language
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
): Promise<LowStockItem[]> {
|
): Promise<LowStockItem[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -150,15 +199,152 @@ async function getMedicationsNeedingReminder(
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const takenDoseRows = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
|
for (const dose of takenDoseRows) {
|
||||||
|
const parts = dose.doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const medId = parseInt(parts[0], 10);
|
||||||
|
if (Number.isNaN(medId)) continue;
|
||||||
|
|
||||||
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
|
}
|
||||||
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
let takenAtMs: number;
|
||||||
|
if (Number.isFinite(rawTakenAt)) {
|
||||||
|
takenAtMs = rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||||
|
} else {
|
||||||
|
takenAtMs = new Date(dose.takenAt).getTime();
|
||||||
|
}
|
||||||
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
const lowStock: LowStockItem[] = [];
|
const lowStock: LowStockItem[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const intakes = parseIntakesJson(
|
||||||
const totalPills =
|
row.intakesJson,
|
||||||
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
|
row.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||||
|
|
||||||
|
const originalTotalPills =
|
||||||
(row.packageType ?? "blister") === "bottle"
|
(row.packageType ?? "blister") === "bottle"
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
|
||||||
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
|
|
||||||
|
let consumed = 0;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
|
let effectiveStart: number;
|
||||||
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
|
} else {
|
||||||
|
effectiveStart = blisterStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
|
let peopleForThisIntake: Array<string | null>;
|
||||||
|
if (intakePerson) {
|
||||||
|
peopleForThisIntake = [intakePerson];
|
||||||
|
} else if (fallbackPeople.length > 0) {
|
||||||
|
peopleForThisIntake = fallbackPeople;
|
||||||
|
} else {
|
||||||
|
peopleForThisIntake = [null];
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeBasedConsumed = 0;
|
||||||
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|
||||||
|
if (effectiveStart <= now) {
|
||||||
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
|
|
||||||
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
|
lastAutoConsumedDateMs = new Date(
|
||||||
|
lastDoseTime.getFullYear(),
|
||||||
|
lastDoseTime.getMonth(),
|
||||||
|
lastDoseTime.getDate()
|
||||||
|
).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start);
|
||||||
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStart.getFullYear(),
|
||||||
|
blisterStart.getMonth(),
|
||||||
|
blisterStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
|
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
|
consumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||||
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||||
|
|
||||||
if (daysLeft === null) continue;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
@@ -168,7 +354,7 @@ async function getMedicationsNeedingReminder(
|
|||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
medsLeft: totalPills,
|
medsLeft: currentPills,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
isCritical,
|
isCritical,
|
||||||
@@ -200,6 +386,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||||
|
export async function getMedicationsNeedingReminderForTests(
|
||||||
|
userId: number,
|
||||||
|
reminderDaysBefore: number,
|
||||||
|
lowStockDays: number,
|
||||||
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
name: string;
|
||||||
|
medsLeft: number;
|
||||||
|
daysLeft: number | null;
|
||||||
|
depletionDate: string | null;
|
||||||
|
isCritical: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendReminderEmail(
|
async function sendReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
lowStock: LowStockItem[],
|
lowStock: LowStockItem[],
|
||||||
@@ -275,8 +480,10 @@ async function sendReminderEmail(
|
|||||||
.map((row) => {
|
.map((row) => {
|
||||||
const isEmpty = row.medsLeft <= 0;
|
const isEmpty = row.medsLeft <= 0;
|
||||||
const isCritical = row.isCritical;
|
const isCritical = row.isCritical;
|
||||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||||
|
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||||
|
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||||
return `
|
return `
|
||||||
<tr style="background: ${rowBg};">
|
<tr style="background: ${rowBg};">
|
||||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||||
@@ -329,7 +536,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
|
|||||||
---
|
---
|
||||||
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||||
|
|
||||||
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
|
const pluralSuffix = language === "de" ? "e" : "s";
|
||||||
|
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
||||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -400,12 +608,18 @@ async function checkAndSendReminderForUser(
|
|||||||
settings.userId,
|
settings.userId,
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
language
|
language,
|
||||||
|
settings.stockCalculationMode
|
||||||
);
|
);
|
||||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||||
|
|
||||||
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
|
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
|
||||||
|
if (!stockSendLock) {
|
||||||
|
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
|
||||||
);
|
);
|
||||||
@@ -460,7 +674,7 @@ async function checkAndSendReminderForUser(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -470,7 +684,8 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
const currentState = loadReminderState();
|
const currentState = loadReminderState();
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
saveReminderState({
|
saveReminderState({
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
lastAutoEmailDate: today,
|
lastAutoEmailDate: today,
|
||||||
@@ -480,15 +695,25 @@ async function checkAndSendReminderForUser(
|
|||||||
lastNotificationChannel: channel,
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstMed = allLowStock[0];
|
|
||||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(stockSendLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
|
||||||
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
|
||||||
|
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
|
||||||
|
if (!prescriptionSendLock) {
|
||||||
|
logger.debug(
|
||||||
|
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
logger.info(
|
logger.info(
|
||||||
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
|
||||||
);
|
);
|
||||||
@@ -536,15 +761,18 @@ async function checkAndSendReminderForUser(
|
|||||||
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
|
||||||
|
|
||||||
const bodyText =
|
const bodyText =
|
||||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
|
||||||
const alertText =
|
|
||||||
emptyRx.length > 0
|
emptyRx.length > 0
|
||||||
? emptyRx.length === 1
|
? tr.prescriptionReminder.descriptionEmpty
|
||||||
|
: tr.prescriptionReminder.descriptionLow;
|
||||||
|
const emptyAlert =
|
||||||
|
emptyRx.length === 1
|
||||||
? tr.prescriptionReminder.alertEmptySingle
|
? tr.prescriptionReminder.alertEmptySingle
|
||||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||||
: lowRx.length === 1
|
const lowAlert =
|
||||||
|
lowRx.length === 1
|
||||||
? tr.prescriptionReminder.alertLowSingle
|
? tr.prescriptionReminder.alertLowSingle
|
||||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||||
|
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||||
|
|
||||||
const tableRows = allPrescriptionLow
|
const tableRows = allPrescriptionLow
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
@@ -649,7 +877,7 @@ async function checkAndSendReminderForUser(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||||
shoutrrrSuccess = result.success;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -659,7 +887,8 @@ async function checkAndSendReminderForUser(
|
|||||||
|
|
||||||
if (emailSuccess || shoutrrrSuccess) {
|
if (emailSuccess || shoutrrrSuccess) {
|
||||||
const currentState = loadReminderState();
|
const currentState = loadReminderState();
|
||||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
const singleChannel = emailSuccess ? "email" : "push";
|
||||||
|
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||||
saveReminderState({
|
saveReminderState({
|
||||||
lastAutoEmailSent: new Date().toISOString(),
|
lastAutoEmailSent: new Date().toISOString(),
|
||||||
lastAutoEmailDate: today,
|
lastAutoEmailDate: today,
|
||||||
@@ -669,15 +898,19 @@ async function checkAndSendReminderForUser(
|
|||||||
lastNotificationChannel: channel,
|
lastNotificationChannel: channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstMed = allPrescriptionLow[0];
|
|
||||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseReminderSendLock(prescriptionSendLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let schedulerTimeout: NodeJS.Timeout | null = null;
|
let schedulerTimeout: NodeJS.Timeout | null = null;
|
||||||
|
let schedulerStarted = false;
|
||||||
|
|
||||||
function scheduleNextCheck(logger: ServiceLogger): void {
|
function scheduleNextCheck(logger: ServiceLogger): void {
|
||||||
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
|
||||||
@@ -702,6 +935,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startReminderScheduler(logger: ServiceLogger): void {
|
export function startReminderScheduler(logger: ServiceLogger): void {
|
||||||
|
if (schedulerStarted) {
|
||||||
|
logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulerStarted = true;
|
||||||
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
|
||||||
|
|
||||||
// Check if we need to run immediately (missed today's check)
|
// Check if we need to run immediately (missed today's check)
|
||||||
@@ -721,9 +959,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
|
|||||||
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runReminderSchedulerNow(logger: ServiceLogger): Promise<void> {
|
||||||
|
logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`);
|
||||||
|
await checkAndSendReminder(logger);
|
||||||
|
}
|
||||||
|
|
||||||
export function stopReminderScheduler(): void {
|
export function stopReminderScheduler(): void {
|
||||||
if (schedulerTimeout) {
|
if (schedulerTimeout) {
|
||||||
clearTimeout(schedulerTimeout);
|
clearTimeout(schedulerTimeout);
|
||||||
schedulerTimeout = null;
|
schedulerTimeout = null;
|
||||||
}
|
}
|
||||||
|
schedulerStarted = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,8 +294,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
|
|
||||||
// Should set cookies
|
// Should set cookies
|
||||||
const cookies = response.cookies;
|
const cookies = response.cookies;
|
||||||
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
|
||||||
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should login case-insensitively with different username casing", async () => {
|
it("should login case-insensitively with different username casing", async () => {
|
||||||
@@ -393,7 +393,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -456,7 +456,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -506,7 +506,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -604,7 +604,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -653,7 +653,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -689,7 +689,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -742,7 +742,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
|
||||||
// Delete account
|
// Delete account
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/libsql";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type ClientTestOptions = {
|
||||||
|
dirWritable?: boolean;
|
||||||
|
authEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDbClientModule(options: ClientTestOptions = {}) {
|
||||||
|
const { dirWritable = true, authEnabled = false } = options;
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
process.env.AUTH_ENABLED = authEnabled ? "true" : "false";
|
||||||
|
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
|
||||||
|
|
||||||
|
const existsSync = vi.fn().mockReturnValue(false);
|
||||||
|
const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 });
|
||||||
|
vi.doMock("node:fs", () => ({ existsSync, statSync }));
|
||||||
|
|
||||||
|
const dotenvConfig = vi.fn();
|
||||||
|
vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } }));
|
||||||
|
|
||||||
|
const createClient = vi.fn().mockReturnValue({ execute: vi.fn() });
|
||||||
|
vi.doMock("@libsql/client", () => ({ createClient }));
|
||||||
|
|
||||||
|
const drizzle = vi.fn().mockReturnValue({ __db: true });
|
||||||
|
vi.doMock("drizzle-orm/libsql", () => ({ drizzle }));
|
||||||
|
|
||||||
|
const ensureDataDirectory = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
|
||||||
|
const getDbPaths = vi.fn().mockReturnValue({
|
||||||
|
dataDir: "/tmp/medassist-data",
|
||||||
|
dbPath: "/tmp/medassist-data/medassist.db",
|
||||||
|
url: "file:/tmp/medassist-data/medassist.db",
|
||||||
|
});
|
||||||
|
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
|
||||||
|
const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||||
|
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
|
||||||
|
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
|
||||||
|
|
||||||
|
vi.doMock("../db/db-utils.js", () => ({
|
||||||
|
buildDbUrl: vi.fn(),
|
||||||
|
getDataDir: vi.fn(),
|
||||||
|
ensureDataDirectory,
|
||||||
|
getDbPaths,
|
||||||
|
runDrizzleMigrations,
|
||||||
|
runAlterMigrations,
|
||||||
|
repairTrailingHyphenDoseIds,
|
||||||
|
repairOrphanedDoseIds,
|
||||||
|
ensureDefaultUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.doMock("../utils/logger.js", () => ({ log }));
|
||||||
|
|
||||||
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
const modulePromise = import("../db/client.js");
|
||||||
|
|
||||||
|
return {
|
||||||
|
modulePromise,
|
||||||
|
mocks: {
|
||||||
|
existsSync,
|
||||||
|
statSync,
|
||||||
|
dotenvConfig,
|
||||||
|
createClient,
|
||||||
|
drizzle,
|
||||||
|
ensureDataDirectory,
|
||||||
|
getDbPaths,
|
||||||
|
runDrizzleMigrations,
|
||||||
|
runAlterMigrations,
|
||||||
|
repairTrailingHyphenDoseIds,
|
||||||
|
repairOrphanedDoseIds,
|
||||||
|
ensureDefaultUser,
|
||||||
|
log,
|
||||||
|
exitSpy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("db/client bootstrap", () => {
|
||||||
|
it("initializes db and runs migrations when directory is writable", async () => {
|
||||||
|
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false });
|
||||||
|
const mod = await modulePromise;
|
||||||
|
|
||||||
|
expect(mod.db).toBeTruthy();
|
||||||
|
expect(mod.migrationsReady).toBeInstanceOf(Promise);
|
||||||
|
await mod.migrationsReady;
|
||||||
|
|
||||||
|
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
|
||||||
|
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
|
||||||
|
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes auth-enabled flag to ensureDefaultUser", async () => {
|
||||||
|
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true });
|
||||||
|
const mod = await modulePromise;
|
||||||
|
await mod.migrationsReady;
|
||||||
|
|
||||||
|
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when data directory is not writable", async () => {
|
||||||
|
const { modulePromise } = await loadDbClientModule({ dirWritable: false });
|
||||||
|
await expect(modulePromise).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -271,7 +271,7 @@ describe("Dose Tracking API", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.doses).toHaveLength(2);
|
expect(data.doses).toHaveLength(2);
|
||||||
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||||
// Each dose should have a takenAt timestamp
|
// Each dose should have a takenAt timestamp
|
||||||
for (const dose of data.doses) {
|
for (const dose of data.doses) {
|
||||||
expect(dose.takenAt).toBeTypeOf("number");
|
expect(dose.takenAt).toBeTypeOf("number");
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
|
|||||||
const { settingsRoutes } = await import("../routes/settings.js");
|
const { settingsRoutes } = await import("../routes/settings.js");
|
||||||
const { healthRoutes } = await import("../routes/health.js");
|
const { healthRoutes } = await import("../routes/health.js");
|
||||||
const { refillRoutes } = await import("../routes/refills.js");
|
const { refillRoutes } = await import("../routes/refills.js");
|
||||||
|
const { reportRoutes } = await import("../routes/report.js");
|
||||||
const { exportRoutes } = await import("../routes/export.js");
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -137,6 +138,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
@@ -167,6 +171,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -261,11 +266,80 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
|
|
||||||
await app.ready();
|
await app.ready();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Report Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Real /medications/report-data route", () => {
|
||||||
|
it("should return 400 for invalid payload", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when requested medication is not owned by user", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [999999] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json().error).toBe("Access denied to medication");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should aggregate taken/dismissed doses and refill history", async () => {
|
||||||
|
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||||
|
|
||||||
|
// One taken dose and one dismissed dose for the same medication
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-1735344000000`, 1735344000],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 1)`,
|
||||||
|
args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400],
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 2, 5, 1, 1735516800],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data[medId].dosesTaken).toBe(1);
|
||||||
|
expect(data[medId].dosesDismissed).toBe(1);
|
||||||
|
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
|
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
|
expect(data[medId].refills).toHaveLength(1);
|
||||||
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
|
packsAdded: 2,
|
||||||
|
loosePillsAdded: 5,
|
||||||
|
usedPrescription: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
testClient.close();
|
testClient.close();
|
||||||
@@ -744,6 +818,39 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const data = getResponse.json();
|
const data = getResponse.json();
|
||||||
expect(data.repeatDailyReminders).toBe(false);
|
expect(data.repeatDailyReminders).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject invalid language in lightweight language endpoint", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "fr" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("Invalid language");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create and update language via lightweight language endpoint", async () => {
|
||||||
|
let response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "de" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "en" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const getResponse = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(getResponse.json().language).toBe("en");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1671,7 +1778,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
expect(medsResponse.statusCode).toBe(200);
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
const med = medsResponse.json().find((m: any) => m.id === medId);
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.prescriptionRemainingRefills).toBe(1);
|
expect(med.prescriptionRemainingRefills).toBe(1);
|
||||||
|
|
||||||
const historyResponse = await app.inject({
|
const historyResponse = await app.inject({
|
||||||
@@ -1809,8 +1916,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
const refills = response.json();
|
const refills = response.json();
|
||||||
expect(refills).toHaveLength(2);
|
expect(refills).toHaveLength(2);
|
||||||
// Check both refills exist (order may vary)
|
// Check both refills exist (order may vary)
|
||||||
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||||
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
|
const hasLooseRefill = refills.some(
|
||||||
|
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
|
||||||
|
);
|
||||||
expect(hasPackRefill).toBe(true);
|
expect(hasPackRefill).toBe(true);
|
||||||
expect(hasLooseRefill).toBe(true);
|
expect(hasLooseRefill).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1888,7 +1997,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(getResponse.statusCode).toBe(200);
|
expect(getResponse.statusCode).toBe(200);
|
||||||
const meds = getResponse.json();
|
const meds = getResponse.json();
|
||||||
const med = meds.find((m: any) => m.id === medId);
|
const med = meds.find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med).toBeDefined();
|
expect(med).toBeDefined();
|
||||||
expect(med.stockAdjustment).toBe(-7);
|
expect(med.stockAdjustment).toBe(-7);
|
||||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||||
@@ -1934,7 +2043,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = getResponse.json().find((m: any) => m.id === medId);
|
const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.name).toBe("Renamed Med");
|
expect(med.name).toBe("Renamed Med");
|
||||||
expect(med.stockAdjustment).toBe(-5);
|
expect(med.stockAdjustment).toBe(-5);
|
||||||
});
|
});
|
||||||
@@ -2003,7 +2112,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// Verify adjustment is set
|
// Verify adjustment is set
|
||||||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
let med = getMeds.json().find((m: any) => m.id === medId);
|
let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.stockAdjustment).toBe(-10);
|
expect(med.stockAdjustment).toBe(-10);
|
||||||
|
|
||||||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||||||
@@ -2022,7 +2131,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// stockAdjustment should be reset to 0
|
// stockAdjustment should be reset to 0
|
||||||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
med = getMeds.json().find((m: any) => m.id === medId);
|
med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -2066,7 +2175,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
// stockAdjustment should be preserved
|
// stockAdjustment should be preserved
|
||||||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||||
const med = getMeds.json().find((m: any) => m.id === medId);
|
const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.name).toBe("Renamed Preserve Med");
|
expect(med.name).toBe("Renamed Preserve Med");
|
||||||
expect(med.stockAdjustment).toBe(-5);
|
expect(med.stockAdjustment).toBe(-5);
|
||||||
});
|
});
|
||||||
@@ -2114,7 +2223,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
const med = data.find((m: any) => m.medicationId === medId);
|
const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
|
||||||
expect(med).toBeDefined();
|
expect(med).toBeDefined();
|
||||||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||||||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||||||
@@ -2201,6 +2310,87 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.settings).toBeDefined();
|
expect(data.settings).toBeDefined();
|
||||||
expect(data.settings.emailEnabled).toBe(true);
|
expect(data.settings.emailEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include sensitive settings when requested", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: "",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: true,
|
||||||
|
shoutrrrUrl: "https://example.com/topic",
|
||||||
|
emailStockReminders: false,
|
||||||
|
emailIntakeReminders: false,
|
||||||
|
emailPrescriptionReminders: false,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export?includeSensitive=true",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.settings.shoutrrrEnabled).toBe(true);
|
||||||
|
expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should gracefully export malformed date-like DB values", async () => {
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Date Edge Med",
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const medId = createResponse.json().id as number;
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-1735344000000`, "not-a-date"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [medId, userId, 1, 0, 0, "still-not-a-date"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
args: [userId, "date-edge-token", "Daniel", 30, "broken-date"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/export" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const data = response.json();
|
||||||
|
expect(data.doseHistory).toHaveLength(1);
|
||||||
|
expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false);
|
||||||
|
expect(data.refillHistory).toHaveLength(1);
|
||||||
|
expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false);
|
||||||
|
expect(data.shareLinks).toHaveLength(1);
|
||||||
|
expect(data.shareLinks[0].expiresAt).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Real /import routes", () => {
|
describe("Real /import routes", () => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
describe("plugins/env runtime validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env = {
|
||||||
|
...ORIGINAL_ENV,
|
||||||
|
DOTENV_PATH: "/tmp/medassist-nonexistent.env",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = ORIGINAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads with defaults when auth and oidc are disabled", async () => {
|
||||||
|
delete process.env.AUTH_ENABLED;
|
||||||
|
delete process.env.OIDC_ENABLED;
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.REFRESH_SECRET;
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
|
||||||
|
const mod = await import("../plugins/env.js");
|
||||||
|
expect(mod.env.AUTH_ENABLED).toBe(false);
|
||||||
|
expect(mod.env.OIDC_ENABLED).toBe(false);
|
||||||
|
expect(mod.env.PORT).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when auth is enabled but secrets are missing", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "true";
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.REFRESH_SECRET;
|
||||||
|
delete process.env.COOKIE_SECRET;
|
||||||
|
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits when oidc is enabled but required settings are missing", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "false";
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
delete process.env.OIDC_ISSUER_URL;
|
||||||
|
delete process.env.OIDC_CLIENT_ID;
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
delete process.env.OIDC_REDIRECT_URI;
|
||||||
|
|
||||||
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads when auth and oidc settings are complete", async () => {
|
||||||
|
process.env.AUTH_ENABLED = "true";
|
||||||
|
process.env.JWT_SECRET = "jwt-secret-for-runtime-test";
|
||||||
|
process.env.REFRESH_SECRET = "refresh-secret-runtime-test";
|
||||||
|
process.env.COOKIE_SECRET = "cookie-secret-runtime-test";
|
||||||
|
process.env.OIDC_ENABLED = "true";
|
||||||
|
process.env.OIDC_ISSUER_URL = "https://auth.example.com";
|
||||||
|
process.env.OIDC_CLIENT_ID = "medassist";
|
||||||
|
process.env.OIDC_CLIENT_SECRET = "super-secret-client";
|
||||||
|
process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback";
|
||||||
|
|
||||||
|
const mod = await import("../plugins/env.js");
|
||||||
|
expect(mod.env.AUTH_ENABLED).toBe(true);
|
||||||
|
expect(mod.env.OIDC_ENABLED).toBe(true);
|
||||||
|
expect(mod.env.OIDC_CLIENT_ID).toBe("medassist");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
// Mock process.exit to prevent tests from exiting
|
// Mock process.exit to prevent tests from exiting
|
||||||
const mockExit = vi.fn();
|
const mockExit = vi.fn();
|
||||||
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
|
||||||
|
|
||||||
// Re-create the schema from env.ts for testing
|
// Re-create the schema from env.ts for testing
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
const userId = 1; // Test user ID
|
const userId = 1; // Test user ID
|
||||||
|
|
||||||
// Helper to parse blisters from DB
|
// Helper to parse blisters from DB
|
||||||
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
function parseBlisters(
|
||||||
const usage = JSON.parse(row.usage_json || "[]") as number[];
|
row: Record<string, unknown>
|
||||||
const every = JSON.parse(row.every_json || "[]") as number[];
|
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||||
const start = JSON.parse(row.start_json || "[]") as string[];
|
const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
|
||||||
|
const every = JSON.parse((row.every_json as string) || "[]") as number[];
|
||||||
|
const start = JSON.parse((row.start_json as string) || "[]") as string[];
|
||||||
const len = Math.min(usage.length, every.length, start.length);
|
const len = Math.min(usage.length, every.length, start.length);
|
||||||
return Array.from({ length: len }, (_, i) => ({
|
return Array.from({ length: len }, (_, i) => ({
|
||||||
usage: usage[i],
|
usage: usage[i],
|
||||||
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
args: [userId],
|
args: [userId],
|
||||||
});
|
});
|
||||||
|
|
||||||
let settings;
|
let settings: Record<string, unknown> | undefined;
|
||||||
if (settingsResult.rows.length > 0) {
|
if (settingsResult.rows.length > 0) {
|
||||||
const s = settingsResult.rows[0];
|
const s = settingsResult.rows[0];
|
||||||
settings = {
|
settings = {
|
||||||
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /import
|
// POST /import
|
||||||
app.post<{ Body: any }>("/import", async (request, reply) => {
|
app.post("/import", async (request, reply) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||||
const importData = request.body as any;
|
const importData = request.body as any;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
|
|||||||
// Import medications
|
// Import medications
|
||||||
const exportIdToNewId = new Map<string, number>();
|
const exportIdToNewId = new Map<string, number>();
|
||||||
for (const med of importData.medications || []) {
|
for (const med of importData.medications || []) {
|
||||||
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
|
const usageJson = JSON.stringify(
|
||||||
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
|
||||||
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
|
);
|
||||||
|
const everyJson = JSON.stringify(
|
||||||
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
|
||||||
|
);
|
||||||
|
const startJson = JSON.stringify(
|
||||||
|
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
|
||||||
|
);
|
||||||
const takenByJson = JSON.stringify(med.takenBy || []);
|
const takenByJson = JSON.stringify(med.takenBy || []);
|
||||||
|
|
||||||
const result = await client.execute({
|
const result = await client.execute({
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
@@ -162,6 +165,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
@@ -1333,8 +1337,8 @@ describe("Integration Tests", () => {
|
|||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const meds = medsRes.json();
|
const meds = medsRes.json();
|
||||||
const med1 = meds.find((m: any) => m.id === med1Id);
|
const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
|
||||||
const med2 = meds.find((m: any) => m.id === med2Id);
|
const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
|
||||||
|
|
||||||
expect(med1.dismissedUntil).toBe("2025-01-15");
|
expect(med1.dismissedUntil).toBe("2025-01-15");
|
||||||
expect(med2.dismissedUntil).toBe("2025-01-15");
|
expect(med2.dismissedUntil).toBe("2025-01-15");
|
||||||
@@ -1376,7 +1380,7 @@ describe("Integration Tests", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.dismissedUntil).toBeNull();
|
expect(med.dismissedUntil).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1446,7 +1450,7 @@ describe("Integration Tests", () => {
|
|||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
});
|
});
|
||||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
expect(med.dismissedUntil).toBeNull();
|
expect(med.dismissedUntil).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type OidcMocks = {
|
||||||
|
discovery: ReturnType<typeof vi.fn>;
|
||||||
|
buildAuthorizationUrl: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
OIDC_ENABLED: true,
|
||||||
|
OIDC_ISSUER_URL: "https://issuer.example.com",
|
||||||
|
OIDC_CLIENT_ID: "medassist-client",
|
||||||
|
OIDC_CLIENT_SECRET: "medassist-client-secret",
|
||||||
|
OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback",
|
||||||
|
OIDC_SCOPES: "openid profile email",
|
||||||
|
OIDC_AUTO_CREATE_USERS: true,
|
||||||
|
OIDC_USERNAME_CLAIM: "preferred_username",
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
CORS_ORIGINS: "http://localhost:5173",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
...envOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.doMock("../plugins/env.js", () => ({ env }));
|
||||||
|
|
||||||
|
vi.doMock("../db/client.js", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })),
|
||||||
|
})),
|
||||||
|
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" });
|
||||||
|
const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => {
|
||||||
|
const state = typeof params?.state === "string" ? params.state : "state";
|
||||||
|
return new URL(`https://issuer.example.com/authorize?state=${state}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("openid-client", () => ({
|
||||||
|
discovery,
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
authorizationCodeGrant: vi.fn(),
|
||||||
|
fetchUserInfo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||||
|
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
|
app.decorate("config", {
|
||||||
|
accessSecret: "test-jwt-secret-12345",
|
||||||
|
refreshSecret: "test-refresh-secret-12345",
|
||||||
|
accessTtl: 15 * 60,
|
||||||
|
refreshTtl: 7 * 24 * 60 * 60,
|
||||||
|
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||||
|
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" },
|
||||||
|
});
|
||||||
|
await app.register(oidcRoutes);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
mocks: { discovery, buildAuthorizationUrl } as OidcMocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OIDC routes", () => {
|
||||||
|
it("returns 400 on login and callback when oidc is disabled", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: false });
|
||||||
|
try {
|
||||||
|
const login = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||||
|
const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||||
|
|
||||||
|
expect(login.statusCode).toBe(400);
|
||||||
|
expect(callback.statusCode).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => {
|
||||||
|
const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toContain("https://issuer.example.com/authorize");
|
||||||
|
expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true);
|
||||||
|
expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true);
|
||||||
|
expect(mocks.discovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with provider error when callback contains error params", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when callback is missing required params", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when callback state validation fails", async () => {
|
||||||
|
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||||
|
try {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/auth/oidc/callback?code=abc123&state=state123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(302);
|
||||||
|
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
|
|||||||
|
|
||||||
// Mock sendShoutrrrNotification from settings
|
// Mock sendShoutrrrNotification from settings
|
||||||
vi.mock("../routes/settings.js", async (importOriginal) => {
|
vi.mock("../routes/settings.js", async (importOriginal) => {
|
||||||
const original = (await importOriginal()) as any;
|
const original = (await importOriginal()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
sendShoutrrrNotification: mockSendShoutrrr,
|
sendShoutrrrNotification: mockSendShoutrrr,
|
||||||
@@ -149,6 +149,9 @@ async function createSchema(client: Client) {
|
|||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
const env = {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: env,
|
||||||
|
nodemailerSendMail: vi.fn(),
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../plugins/auth.js", () => ({
|
||||||
|
requireAuth: async () => {},
|
||||||
|
getAnonymousUserId: async () => 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
default: {
|
||||||
|
createTransport: () => ({
|
||||||
|
sendMail: nodemailerSendMail,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { settingsRoutes, sendShoutrrrNotification } = await import("../routes/settings.js");
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
const { reportRoutes } = await import("../routes/report.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAnonymousUser() {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||||
|
args: [1, "anon", "anonymous"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMedication(name = "Aspirin") {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, generic_name, taken_by_json, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
stock_adjustment, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
"Acetylsalicylic acid",
|
||||||
|
JSON.stringify(["Daniel"]),
|
||||||
|
"blister",
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify(["2026-01-01T08:00:00.000Z"]),
|
||||||
|
JSON.stringify([
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", takenBy: "Daniel", intakeRemindersEnabled: true },
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return result.rows[0].id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Real route coverage: settings/export/report", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(settingsRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.register(reportRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
await clearTables();
|
||||||
|
await seedAnonymousUser();
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
delete process.env.SMTP_USER;
|
||||||
|
delete process.env.SMTP_TOKEN;
|
||||||
|
delete process.env.SMTP_PASS;
|
||||||
|
delete process.env.SMTP_FROM;
|
||||||
|
delete process.env.SMTP_PORT;
|
||||||
|
delete process.env.SMTP_SECURE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /settings creates defaults for anonymous user", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.language).toBe("en");
|
||||||
|
expect(body.shareStockStatus).toBe(true);
|
||||||
|
expect(body.upcomingTodayOnly).toBe(false);
|
||||||
|
expect(body.shareScheduleTodayOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /settings disables repeatDailyReminders when no stock reminder channel exists", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings",
|
||||||
|
payload: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: "",
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: true,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: "",
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const stored = await testClient.execute({
|
||||||
|
sql: "SELECT repeat_daily_reminders FROM user_settings WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(stored.rows[0].repeat_daily_reminders).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /settings/language validates supported language", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: "/settings/language",
|
||||||
|
payload: { language: "fr" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("Invalid language");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-email fails when SMTP is not configured", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json().error).toBe("SMTP not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-email sends email when SMTP is configured", async () => {
|
||||||
|
process.env.SMTP_HOST = "smtp.example.com";
|
||||||
|
process.env.SMTP_USER = "mailer@example.com";
|
||||||
|
process.env.SMTP_TOKEN = "secret";
|
||||||
|
nodemailerSendMail.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-email",
|
||||||
|
payload: { email: "person@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(nodemailerSendMail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /settings/test-shoutrrr validates URL presence", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/settings/test-shoutrrr",
|
||||||
|
payload: { url: "" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||||
|
const result = await sendShoutrrrNotification("http://127.0.0.1/hook", "test", "message");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("not allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://ntfy.sh/mytopic",
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: expect.stringMatching(/^Basic /),
|
||||||
|
}),
|
||||||
|
method: "POST",
|
||||||
|
redirect: "error",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendShoutrrrNotification uses JSON payload for webhook URLs", async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true });
|
||||||
|
const result = await sendShoutrrrNotification("https://hooks.slack.com/services/a/b/c", "Title", "Body");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(call[1].headers["Content-Type"]).toBe("application/json");
|
||||||
|
expect(JSON.parse(call[1].body)).toMatchObject({ title: "Title", message: "Body" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data returns 403 for meds not owned by user", async () => {
|
||||||
|
await seedMedication("Owned Med");
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [9999] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data aggregates doses and refills", async () => {
|
||||||
|
const medId = await seedMedication("Report Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, 0],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000600000-Daniel`, 1700000600, 1],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 1, 2, 1, 1700001200],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
|
expect(body[medId].dosesDismissed).toBe(1);
|
||||||
|
expect(body[medId].refills).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||||
|
const medId = await seedMedication("Export Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Daniel`, 1700000000, "Daniel"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 1, 3, 0, 1700000000],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO user_settings (user_id, email_enabled, notification_email, share_stock_status, language) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
args: [1, 1, "x@example.com", 1, "de"],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, "abc123", "Daniel", 30],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export?includeSensitive=true&includeImages=false",
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.medications).toHaveLength(1);
|
||||||
|
expect(body.doseHistory).toHaveLength(1);
|
||||||
|
expect(body.refillHistory).toHaveLength(1);
|
||||||
|
expect(body.settings.language).toBe("de");
|
||||||
|
expect(body.shareLinks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /import validates payload and imports minimal valid structure", async () => {
|
||||||
|
const invalid = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: { foo: "bar" },
|
||||||
|
});
|
||||||
|
expect(invalid.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const validImport = {
|
||||||
|
version: "1.1",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
includeSensitiveData: false,
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Med",
|
||||||
|
genericName: null,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
inventory: {
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
totalPills: null,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packageType: "blister",
|
||||||
|
},
|
||||||
|
pillWeightMg: null,
|
||||||
|
doseUnit: "mg",
|
||||||
|
schedules: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", remind: false, takenBy: "Daniel" }],
|
||||||
|
medicationStartDate: "",
|
||||||
|
expiryDate: null,
|
||||||
|
notes: null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
isObsolete: false,
|
||||||
|
obsoleteAt: null,
|
||||||
|
prescriptionEnabled: false,
|
||||||
|
prescriptionAuthorizedRefills: null,
|
||||||
|
prescriptionRemainingRefills: null,
|
||||||
|
prescriptionLowRefillThreshold: 1,
|
||||||
|
prescriptionExpiryDate: null,
|
||||||
|
dismissedUntil: null,
|
||||||
|
image: null,
|
||||||
|
lastStockCorrectionAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doseHistory: [],
|
||||||
|
refillHistory: [],
|
||||||
|
settings: {
|
||||||
|
emailEnabled: false,
|
||||||
|
notificationEmail: null,
|
||||||
|
emailStockReminders: true,
|
||||||
|
emailIntakeReminders: true,
|
||||||
|
emailPrescriptionReminders: true,
|
||||||
|
shoutrrrEnabled: false,
|
||||||
|
shoutrrrUrl: null,
|
||||||
|
shoutrrrStockReminders: true,
|
||||||
|
shoutrrrIntakeReminders: true,
|
||||||
|
shoutrrrPrescriptionReminders: true,
|
||||||
|
reminderDaysBefore: 7,
|
||||||
|
repeatDailyReminders: false,
|
||||||
|
skipRemindersForTakenDoses: false,
|
||||||
|
repeatRemindersEnabled: false,
|
||||||
|
reminderRepeatIntervalMinutes: 30,
|
||||||
|
maxNaggingReminders: 5,
|
||||||
|
lowStockDays: 30,
|
||||||
|
normalStockDays: 90,
|
||||||
|
highStockDays: 180,
|
||||||
|
expiryWarningDays: 30,
|
||||||
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: true,
|
||||||
|
},
|
||||||
|
shareLinks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const valid = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: validImport,
|
||||||
|
});
|
||||||
|
expect(valid.statusCode).toBe(200);
|
||||||
|
expect(valid.json().imported.medications).toBe(1);
|
||||||
|
|
||||||
|
const rows = await testClient.execute({
|
||||||
|
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(rows.rows[0].name).toBe("Imported Med");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
|||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import Fastify from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
// Import from utils to avoid index.ts import side effects (server start)
|
// Import from utils to avoid index.ts import side effects (server start)
|
||||||
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
|
|||||||
refreshCookieOptions,
|
refreshCookieOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((app as any).config.accessTtl).toBe(15);
|
const appWithConfig = app as unknown as {
|
||||||
expect((app as any).config.refreshTtl).toBe(7);
|
config: {
|
||||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
accessTtl: number;
|
||||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
refreshTtl: number;
|
||||||
|
cookieOptions: { httpOnly: boolean };
|
||||||
|
refreshCookieOptions: { maxAge: number };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(appWithConfig.config.accessTtl).toBe(15);
|
||||||
|
expect(appWithConfig.config.refreshTtl).toBe(7);
|
||||||
|
expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true);
|
||||||
|
expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
|
|||||||
const app = Fastify({ logger: false });
|
const app = Fastify({ logger: false });
|
||||||
|
|
||||||
// Mock route plugins
|
// Mock route plugins
|
||||||
const healthRoutes = async (app: any) => {
|
const healthRoutes = async (app: FastifyInstance) => {
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const authRoutes = async (app: any) => {
|
const authRoutes = async (app: FastifyInstance) => {
|
||||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const medicationRoutes = async (app: any) => {
|
const medicationRoutes = async (app: FastifyInstance) => {
|
||||||
app.get("/medications", async () => []);
|
app.get("/medications", async () => []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
|
|||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data).toHaveLength(2);
|
expect(data).toHaveLength(2);
|
||||||
|
|
||||||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
|
||||||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
|
||||||
|
|
||||||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||||||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../plugins/auth.js", () => ({
|
||||||
|
requireAuth: async () => {},
|
||||||
|
getAnonymousUserId: async () => 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
|
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAnonymousUser() {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||||
|
args: [1, "anon", "anonymous"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStockMode(mode: "automatic" | "manual") {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||||
|
VALUES (?, ?, 7, 365, 'en')`,
|
||||||
|
args: [1, mode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMedication(options: {
|
||||||
|
name: string;
|
||||||
|
packCount?: number;
|
||||||
|
blistersPerPack?: number;
|
||||||
|
pillsPerBlister?: number;
|
||||||
|
looseTablets?: number;
|
||||||
|
stockAdjustment?: number;
|
||||||
|
lastStockCorrectionAt?: number | null;
|
||||||
|
isObsolete?: boolean;
|
||||||
|
takenBy?: string[];
|
||||||
|
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
packCount = 1,
|
||||||
|
blistersPerPack = 1,
|
||||||
|
pillsPerBlister = 10,
|
||||||
|
looseTablets = 0,
|
||||||
|
stockAdjustment = 0,
|
||||||
|
lastStockCorrectionAt = null,
|
||||||
|
isObsolete = false,
|
||||||
|
takenBy = [],
|
||||||
|
intakes,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||||
|
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||||
|
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||||
|
const intakesJson = JSON.stringify(
|
||||||
|
intakes.map((i) => ({
|
||||||
|
usage: i.usage,
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
takenBy: i.takenBy ?? null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, taken_by_json, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
stock_adjustment, last_stock_correction_at,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
is_obsolete, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
JSON.stringify(takenBy),
|
||||||
|
packCount,
|
||||||
|
blistersPerPack,
|
||||||
|
pillsPerBlister,
|
||||||
|
looseTablets,
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt,
|
||||||
|
usageJson,
|
||||||
|
everyJson,
|
||||||
|
startJson,
|
||||||
|
intakesJson,
|
||||||
|
isObsolete ? 1 : 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markDoseTaken(options: {
|
||||||
|
medicationId: number;
|
||||||
|
blisterIdx: number;
|
||||||
|
doseDateOnlyMs: number;
|
||||||
|
takenAtMs: number;
|
||||||
|
personSuffix?: string;
|
||||||
|
}) {
|
||||||
|
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||||
|
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||||
|
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||||
|
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate, endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const rows = response.json();
|
||||||
|
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnlyMs(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(medicationRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
await seedAnonymousUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps automatic mode current stock in sync", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Auto Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(10);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Correction";
|
||||||
|
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
lastStockCorrectionAt: correctionMs,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||||
|
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||||
|
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan5DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan6DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts early taken dose in automatic mode without drift", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Early Taken";
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
tomorrow.setHours(20, 0, 0, 0);
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: tomorrowDateOnly,
|
||||||
|
takenAtMs: now.getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rangeStart = new Date(now);
|
||||||
|
rangeStart.setDate(now.getDate() - 1);
|
||||||
|
const rangeEnd = new Date(now);
|
||||||
|
rangeEnd.setDate(now.getDate() + 7);
|
||||||
|
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(9);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Mixed TakenBy";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
takenBy: ["Alice", "Bob"],
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
expect(usageRow.currentPills).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
await createMedication({
|
||||||
|
name: "Obsolete Med",
|
||||||
|
isObsolete: true,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||||
|
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -191,7 +191,7 @@ export function parseIntakesJson(
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(intakesJson);
|
const parsed = JSON.parse(intakesJson);
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
return parsed.map((intake: any) => ({
|
return parsed.map((intake: Record<string, unknown>) => ({
|
||||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||||
every: typeof intake.every === "number" ? intake.every : 1,
|
every: typeof intake.every === "number" ? intake.every : 1,
|
||||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||||
@@ -312,7 +312,7 @@ export type UpcomingIntake = {
|
|||||||
export function getTodaysIntakes(
|
export function getTodaysIntakes(
|
||||||
medName: string,
|
medName: string,
|
||||||
intakes: Intake[],
|
intakes: Intake[],
|
||||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
@@ -388,7 +388,7 @@ export function getUpcomingIntakes(
|
|||||||
medName: string,
|
medName: string,
|
||||||
intakes: Intake[],
|
intakes: Intake[],
|
||||||
minutesBefore: number,
|
minutesBefore: number,
|
||||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||||
pillWeightMg: number | null,
|
pillWeightMg: number | null,
|
||||||
locale: string,
|
locale: string,
|
||||||
tz?: string,
|
tz?: string,
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off"
|
"noForEach": "off",
|
||||||
|
"noImportantStyles": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "warn",
|
"noExplicitAny": "warn",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- frontend_node_modules:/app/node_modules
|
- frontend_node_modules:/app/node_modules
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://backend-dev:3000
|
- BACKEND_URL=http://backend-dev:3000
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
await expect(ibuprofenRow).toBeVisible();
|
await expect(ibuprofenRow).toBeVisible();
|
||||||
const rowText = await ibuprofenRow.textContent();
|
const rowText = await ibuprofenRow.textContent();
|
||||||
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
||||||
expect(rowText).toContain("59");
|
expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show today block in timeline", async ({ page }) => {
|
test("should show today block in timeline", async ({ page }) => {
|
||||||
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
|
|||||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
||||||
@@ -154,20 +153,23 @@ test.describe("Dashboard with medications", () => {
|
|||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Normalize state first: if a dose is already taken, undo it so we can
|
||||||
|
// always execute the same take -> undo flow deterministically.
|
||||||
|
const existingUndo = todayBlock.locator("button.dose-btn.undo").first();
|
||||||
|
if (await existingUndo.isVisible().catch(() => false)) {
|
||||||
|
await existingUndo.click();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
|
||||||
// Mark a dose as taken first
|
// Mark a dose as taken first
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
await expect(takeBtn).toBeVisible({ timeout: 10000 });
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Wait for undo button to appear (confirms the take succeeded)
|
// Wait for undo button to appear (confirms the take succeeded)
|
||||||
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
||||||
try {
|
|
||||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
|
||||||
// Take might have been rate-limited — skip this test gracefully
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await undoBtn.click();
|
await undoBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
|||||||
* auth.spec.ts should keep importing from `@playwright/test` directly
|
* auth.spec.ts should keep importing from `@playwright/test` directly
|
||||||
* since it tests the unauthenticated flow.
|
* since it tests the unauthenticated flow.
|
||||||
*/
|
*/
|
||||||
export const test = base.extend<{}>({
|
export const test = base.extend<object>({
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
await setupAuthMeMock(page);
|
await setupAuthMeMock(page);
|
||||||
await use(page);
|
await use(page);
|
||||||
|
|||||||
@@ -38,57 +38,57 @@ async function fillAndSaveMedication(
|
|||||||
intakes?: { usage: string; every: string }[];
|
intakes?: { usage: string; every: string }[];
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
|
||||||
|
if (await openCreateBtn.isVisible().catch(() => false)) {
|
||||||
|
await openCreateBtn.click();
|
||||||
|
}
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
|
||||||
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
|
||||||
if (opts.genericName) {
|
if (opts.genericName) {
|
||||||
await page.getByLabel(/Generic Name/i).fill(opts.genericName);
|
await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageTypeSelect = form.locator("select.package-type-select");
|
||||||
if (opts.packageType === "bottle") {
|
if (opts.packageType === "bottle") {
|
||||||
await page.locator("select.package-type-select").selectOption("bottle");
|
await packageTypeSelect.selectOption("bottle");
|
||||||
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
|
if (opts.totalCapacity)
|
||||||
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||||
|
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||||
} else {
|
} else {
|
||||||
await page.locator("select.package-type-select").selectOption("blister");
|
await packageTypeSelect.selectOption("blister");
|
||||||
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
|
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
|
||||||
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
|
if (opts.blistersPerPack)
|
||||||
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
|
await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
|
||||||
|
if (opts.pillsPerBlister)
|
||||||
|
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
|
||||||
|
if (opts.loosePills) {
|
||||||
|
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
|
||||||
|
if (await looseField.isVisible().catch(() => false)) {
|
||||||
|
await looseField.fill(opts.loosePills);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
|
if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
|
||||||
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
|
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
|
||||||
|
|
||||||
// Fill intake schedules
|
// Fill intake schedules
|
||||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
for (let i = 0; i < intakes.length; i++) {
|
for (let i = 0; i < intakes.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = page.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
|
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
||||||
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every);
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click Save — handle potential rate-limiting by retrying
|
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.locator("form.form-grid button[type='submit']").click();
|
await form.locator("button[type='submit']").click();
|
||||||
|
|
||||||
// Wait for the form to reset: commercial name becomes empty after successful save
|
|
||||||
try {
|
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
|
|
||||||
break; // Save succeeded
|
|
||||||
} catch {
|
|
||||||
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
|
|
||||||
// Save might have been rate-limited — wait and retry
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
// Re-fill the name in case form was partially reset
|
|
||||||
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
|
|
||||||
if (!currentValue) {
|
|
||||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
||||||
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
|
|||||||
* Helper: save after editing (PUT) and wait for success.
|
* Helper: save after editing (PUT) and wait for success.
|
||||||
*/
|
*/
|
||||||
async function saveEdit(page: Page, medName: string): Promise<void> {
|
async function saveEdit(page: Page, medName: string): Promise<void> {
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.locator("form.form-grid button[type='submit']").click();
|
const submitBtn = form.locator("button[type='submit']");
|
||||||
|
if (
|
||||||
|
(await submitBtn.count()) > 0 &&
|
||||||
|
(await submitBtn
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
await submitBtn.first().click();
|
||||||
|
} else {
|
||||||
|
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||||
|
if (await closeBtn.isVisible().catch(() => false)) {
|
||||||
|
await closeBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
// Wait for the list to update with the new name — retry with reload if rate-limited
|
// Wait for the list to update with the new name — retry with reload if rate-limited
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
||||||
try {
|
try {
|
||||||
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
|
|||||||
|
|
||||||
test("should not save with empty commercial name", async ({ page }) => {
|
test("should not save with empty commercial name", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Leave name empty — save button should be disabled
|
// Saving without name should not create a medication row.
|
||||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||||
await expect(saveBtn).toBeDisabled();
|
await expect(saveBtn).toBeVisible();
|
||||||
|
await saveBtn.click();
|
||||||
|
await expect(page.locator(".med-row")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reset form after saving a medication", async ({ page }) => {
|
test("should reset form after saving a medication", async ({ page }) => {
|
||||||
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
|
|||||||
pillsPerBlister: "10",
|
pillsPerBlister: "10",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form should reset — title should say "New medication"
|
// Opening a fresh form after save should start with an empty commercial name.
|
||||||
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
|
await page
|
||||||
// Commercial name should be empty
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => {
|
|||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
|
|
||||||
// Form title should say "Edit medication"
|
// Form title should say "Edit entry" (or legacy "Edit medication").
|
||||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
|
await expect(
|
||||||
|
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// The name field should have the current value
|
// The name field should have the current value
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit");
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("After Edit");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
|
||||||
|
|
||||||
// Save the edit
|
// Save the edit
|
||||||
await saveEdit(page, "After Edit");
|
await saveEdit(page, "After Edit");
|
||||||
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
|
|||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Modified Name");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name");
|
||||||
|
|
||||||
// Click Cancel
|
// Click Cancel
|
||||||
await page.locator("form.form-grid button.ghost").click();
|
await page
|
||||||
|
.getByRole("button", { name: /Close|Cancel/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Original name should still be in the list
|
// Original name should still be in the list
|
||||||
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show refill section in edit mode", async ({ page }) => {
|
|
||||||
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
|
|
||||||
await navigateTo(page, "/medications");
|
|
||||||
|
|
||||||
// Click Edit
|
|
||||||
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
|
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
|
||||||
await medRow.locator("button.info").click();
|
|
||||||
|
|
||||||
// Refill section should be visible
|
|
||||||
const refillSection = page.locator(".refill-section");
|
|
||||||
await expect(refillSection).toBeVisible();
|
|
||||||
await expect(refillSection.locator("button.success")).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Delete medication", () => {
|
test.describe("Delete medication", () => {
|
||||||
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
|
|||||||
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Accept the native confirm() dialog
|
|
||||||
page.on("dialog", (dialog) => dialog.accept());
|
|
||||||
await medRow.locator("button.danger").click();
|
await medRow.locator("button.danger").click();
|
||||||
|
await page
|
||||||
|
.locator(".confirm-modal-overlay, .modal-overlay")
|
||||||
|
.getByRole("button", { name: /Delete/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Medication should be removed
|
// Medication should be removed
|
||||||
await expect(medRow).not.toBeVisible({ timeout: 5000 });
|
await expect(medRow).toHaveCount(0, { timeout: 10000 });
|
||||||
|
|
||||||
// Already deleted via UI — clear tracked list
|
// Already deleted via UI — clear tracked list
|
||||||
createdMeds.length = 0;
|
createdMeds.length = 0;
|
||||||
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
|
|||||||
test.describe("Intake schedule management", () => {
|
test.describe("Intake schedule management", () => {
|
||||||
test("should add and remove intake schedule rows", async ({ page }) => {
|
test("should add and remove intake schedule rows", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
expect(await page.locator(".blister-row").count()).toBe(1);
|
expect(await form.locator(".blister-row").count()).toBe(1);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(3);
|
expect(await form.locator(".blister-row").count()).toBe(3);
|
||||||
|
|
||||||
const removeBtn = page
|
const removeBtn = page
|
||||||
.locator(".blister-row")
|
.locator("form.form-grid:visible .blister-row")
|
||||||
.last()
|
.last()
|
||||||
.getByRole("button", { name: /Remove/i });
|
.getByRole("button", { name: /Remove/i });
|
||||||
await removeBtn.click();
|
await removeBtn.click();
|
||||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
await medRow.locator("button.info").click();
|
await medRow.locator("button.info").click();
|
||||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper: save edit and verify success */
|
/** Helper: save edit and verify success */
|
||||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
// Wait for any pending network before clicking save
|
// Wait for any pending network before clicking save
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Click save
|
const submitBtn = form.locator("button[type='submit']");
|
||||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
if (
|
||||||
await saveBtn.click();
|
(await submitBtn.count()) > 0 &&
|
||||||
|
(await submitBtn
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false))
|
||||||
|
) {
|
||||||
|
await submitBtn.first().click();
|
||||||
|
} else {
|
||||||
|
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||||
|
if (await closeBtn.isVisible().catch(() => false)) {
|
||||||
|
await closeBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for save request + re-fetch to complete
|
// Wait for save request + re-fetch to complete
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
|
|||||||
await clickEditMed(page, "Edit GenName Med");
|
await clickEditMed(page, "Edit GenName Med");
|
||||||
|
|
||||||
// Generic name should be empty initially
|
// Generic name should be empty initially
|
||||||
const genericField = page.getByLabel(/Generic Name/i);
|
const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||||
await expect(genericField).toHaveValue("");
|
await expect(genericField).toHaveValue("");
|
||||||
|
|
||||||
// Add a generic name
|
// Add a generic name
|
||||||
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Click edit again and verify the generic name was saved
|
// Click edit again and verify the generic name was saved
|
||||||
await clickEditMed(page, "Edit GenName Med");
|
await clickEditMed(page, "Edit GenName Med");
|
||||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid");
|
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add notes to an existing medication", async ({ page }) => {
|
test("should add notes to an existing medication", async ({ page }) => {
|
||||||
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Edit Notes Med");
|
await clickEditMed(page, "Edit Notes Med");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Notes should be empty initially
|
// Notes should be empty initially
|
||||||
const notesField = page.getByLabel(/Notes/i);
|
const notesField = page.getByLabel(/(Notes|form\.notes)/i);
|
||||||
await expect(notesField).toHaveValue("");
|
await expect(notesField).toHaveValue("");
|
||||||
|
|
||||||
// Add notes text
|
// Add notes text
|
||||||
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Verify notes were saved by clicking edit again
|
// Verify notes were saved by clicking edit again
|
||||||
await clickEditMed(page, "Edit Notes Med");
|
await clickEditMed(page, "Edit Notes Med");
|
||||||
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast");
|
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add taken-by person to a medication", async ({ page }) => {
|
test("should add taken-by person to a medication", async ({ page }) => {
|
||||||
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Expiry Date Med");
|
await clickEditMed(page, "Expiry Date Med");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Set expiry date to 6 months from now
|
// Set expiry date to 6 months from now
|
||||||
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
const expiryField = page.getByLabel(/Expiry Date/i);
|
const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i);
|
||||||
await expiryField.fill(expiryDate);
|
await expiryField.fill(expiryDate);
|
||||||
await expect(expiryField).toHaveValue(expiryDate);
|
await expect(expiryField).toHaveValue(expiryDate);
|
||||||
|
|
||||||
// Also touch the name field to ensure form is dirty
|
// Also touch the name field to ensure form is dirty
|
||||||
const nameField = page.getByLabel(/Commercial Name/i);
|
// Expiry change itself is enough to persist in the current edit flow.
|
||||||
const currentName = await nameField.inputValue();
|
|
||||||
await nameField.fill(currentName);
|
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Expiry Date Med");
|
await saveEditAndVerify(page, "Expiry Date Med");
|
||||||
|
|
||||||
// Verify expiry date was saved
|
// Verify expiry date was saved
|
||||||
await clickEditMed(page, "Expiry Date Med");
|
await clickEditMed(page, "Expiry Date Med");
|
||||||
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate);
|
await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
|
||||||
});
|
|
||||||
|
|
||||||
test("should use refill feature to add stock in edit mode", async ({ page }) => {
|
|
||||||
createdMeds.push(
|
|
||||||
await createMedicationViaAPI({
|
|
||||||
name: "Refill Test Med",
|
|
||||||
packCount: 1,
|
|
||||||
blistersPerPack: 2,
|
|
||||||
pillsPerBlister: 10,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await navigateTo(page, "/medications");
|
|
||||||
|
|
||||||
await clickEditMed(page, "Refill Test Med");
|
|
||||||
|
|
||||||
// Refill section should be visible in edit mode
|
|
||||||
const refillSection = page.locator(".refill-section");
|
|
||||||
await expect(refillSection).toBeVisible();
|
|
||||||
|
|
||||||
// Set refill values: 2 packs + 5 loose pills
|
|
||||||
await refillSection.getByLabel(/Packs/i).fill("2");
|
|
||||||
await refillSection.getByLabel(/Loose pills/i).fill("5");
|
|
||||||
|
|
||||||
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
|
|
||||||
const preview = refillSection.locator(".refill-preview");
|
|
||||||
await expect(preview).toBeVisible();
|
|
||||||
expect(await preview.textContent()).toContain("45");
|
|
||||||
|
|
||||||
// Click the refill button
|
|
||||||
await refillSection.locator("button.success").click();
|
|
||||||
|
|
||||||
// Wait for the refill to be processed
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit intake schedule usage and interval", async ({ page }) => {
|
test("should edit intake schedule usage and interval", async ({ page }) => {
|
||||||
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
|
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
||||||
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
|
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
|
|
||||||
await usageField.fill("2");
|
await usageField.fill("2");
|
||||||
await everyField.fill("7");
|
await everyField.fill("7");
|
||||||
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
|
|||||||
// Verify the changes persisted
|
// Verify the changes persisted
|
||||||
await clickEditMed(page, "Edit Intake Med");
|
await clickEditMed(page, "Edit Intake Med");
|
||||||
const savedRow = page.locator(".blister-row").first();
|
const savedRow = page.locator(".blister-row").first();
|
||||||
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
|
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||||
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
|
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should add a second intake schedule row", async ({ page }) => {
|
test("should add a second intake schedule row", async ({ page }) => {
|
||||||
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Add Intake Med");
|
await clickEditMed(page, "Add Intake Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Should have 1 intake row initially
|
// Should have 1 intake row initially
|
||||||
await expect(page.locator(".blister-row")).toHaveCount(1);
|
await expect(page.locator(".blister-row")).toHaveCount(1);
|
||||||
|
|
||||||
// Add a second intake
|
// Add a second intake
|
||||||
await page.getByRole("button", { name: /Intake/i }).click();
|
await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
await expect(page.locator(".blister-row")).toHaveCount(2);
|
await expect(page.locator(".blister-row")).toHaveCount(2);
|
||||||
|
|
||||||
// Fill the new intake row
|
// Fill the new intake row
|
||||||
const secondRow = page.locator(".blister-row").nth(1);
|
const secondRow = page.locator(".blister-row").nth(1);
|
||||||
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
|
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
||||||
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
|
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Add Intake Med");
|
await saveEditAndVerify(page, "Add Intake Med");
|
||||||
|
|
||||||
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "Reminder Toggle Med");
|
await clickEditMed(page, "Reminder Toggle Med");
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Find the remind checkbox in the intake row
|
// Find the remind checkbox in the intake row
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
// Should be blister type initially
|
// Should be blister type initially
|
||||||
const packageSelect = page.locator("select.package-type-select");
|
const packageSelect = form.locator("select.package-type-select");
|
||||||
await expect(packageSelect).toHaveValue("blister");
|
await expect(packageSelect).toHaveValue("blister");
|
||||||
|
|
||||||
// Blister-specific fields should be visible
|
// Blister-specific fields are shown in the Package tab.
|
||||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Switch to bottle
|
// Switch to bottle
|
||||||
await packageSelect.selectOption("bottle");
|
await packageSelect.selectOption("bottle");
|
||||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||||
|
|
||||||
// Fill bottle-specific fields
|
// Fill bottle-specific fields
|
||||||
await page.getByLabel(/Total Capacity/i).fill("120");
|
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
|
|||||||
await clickEditMed(page, "Multi Edit Med");
|
await clickEditMed(page, "Multi Edit Med");
|
||||||
|
|
||||||
// Change the name
|
// Change the name
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med");
|
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med");
|
||||||
|
|
||||||
// Add generic name
|
// Add generic name
|
||||||
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
|
await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
|
||||||
|
|
||||||
// Add notes
|
// Add notes
|
||||||
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water.");
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water.");
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
// Add a taken-by person
|
// Add a taken-by person
|
||||||
const takenByInput = page.locator(".tag-input-container input");
|
const takenByInput = page.locator(".tag-input-container input");
|
||||||
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Verify all changes persisted
|
// Verify all changes persisted
|
||||||
await clickEditMed(page, "Fully Edited Med");
|
await clickEditMed(page, "Fully Edited Med");
|
||||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
|
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
|
||||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
|
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
|
||||||
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
|
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
|
||||||
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import { authFile, navigateTo, test } from "./fixtures";
|
|||||||
test.describe("Medications Page", () => {
|
test.describe("Medications Page", () => {
|
||||||
test.use({ storageState: authFile });
|
test.use({ storageState: authFile });
|
||||||
|
|
||||||
|
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
|
||||||
|
|
||||||
async function openMedicationForm(page: Page) {
|
async function openMedicationForm(page: Page) {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
const newMedicationButton = page.getByRole("button", { name: /New medication/i });
|
const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
if (await newMedicationButton.isVisible().catch(() => false)) {
|
if (await nameField.isVisible().catch(() => false)) return;
|
||||||
await newMedicationButton.click();
|
|
||||||
|
const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i });
|
||||||
|
if (await newEntryButton.isVisible().catch(() => false)) {
|
||||||
|
await newEntryButton.click();
|
||||||
|
await expect(nameField).toBeVisible({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +35,8 @@ test.describe("Medications Page", () => {
|
|||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
// Should show either medication entries or the new medication form
|
// Should show either medication entries or the new medication form
|
||||||
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i });
|
const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
|
||||||
const formTitle = page.locator("h2").filter({ hasText: /New medication/i });
|
const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
|
||||||
|
|
||||||
const hasList = await listTitle.isVisible().catch(() => false);
|
const hasList = await listTitle.isVisible().catch(() => false);
|
||||||
const hasForm = await formTitle.isVisible().catch(() => false);
|
const hasForm = await formTitle.isVisible().catch(() => false);
|
||||||
@@ -40,85 +46,92 @@ test.describe("Medications Page", () => {
|
|||||||
|
|
||||||
test("should display the medication form with required fields", async ({ page }) => {
|
test("should display the medication form with required fields", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
const commercialName = page.getByLabel(/Commercial Name/i);
|
const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
await expect(commercialName).toBeVisible();
|
await expect(commercialName).toBeVisible();
|
||||||
|
|
||||||
// Package type selector should exist
|
// Package type selector should exist
|
||||||
await expect(page.getByText(/Package Type/i)).toBeVisible();
|
await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible();
|
||||||
|
|
||||||
// Intake schedule section should exist
|
// Tabbed form should expose navigation to Package/Schedule sections
|
||||||
await expect(page.getByText(/Intake schedule/i)).toBeVisible();
|
await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should fill in medication details", async ({ page }) => {
|
test("should fill in medication details", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
const nameField = page.getByLabel(/Commercial Name/i);
|
const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||||
await nameField.fill("Test Aspirin");
|
await nameField.fill("Test Aspirin");
|
||||||
await expect(nameField).toHaveValue("Test Aspirin");
|
await expect(nameField).toHaveValue("Test Aspirin");
|
||||||
|
|
||||||
const genericField = page.getByLabel(/Generic Name/i);
|
const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||||
await genericField.fill("Acetylsalicylic acid");
|
await genericField.fill("Acetylsalicylic acid");
|
||||||
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have stock inventory fields", async ({ page }) => {
|
test("should have stock inventory fields", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Stock fields should be visible
|
// Package tab should expose stock-related fields for at least one package mode.
|
||||||
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
|
const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first();
|
||||||
|
const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first();
|
||||||
|
|
||||||
// Either blister or bottle fields depending on package type
|
const hasPacks = await packsField.isVisible().catch(() => false);
|
||||||
const blistersField = page.getByLabel(/Blisters per pack/i);
|
const hasTotal = await totalField.isVisible().catch(() => false);
|
||||||
const pillsField = page.getByLabel(/Pills per blister/i);
|
|
||||||
const capacityField = page.getByLabel(/Total Capacity/i);
|
|
||||||
|
|
||||||
const hasBlister = await blistersField.isVisible().catch(() => false);
|
expect(hasPacks || hasTotal).toBeTruthy();
|
||||||
const hasBottle = await capacityField.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
expect(hasBlister || hasBottle).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
test("should toggle package type between blister and bottle", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
|
||||||
// Find the package type radio buttons or selector
|
// Find the package type radio buttons or selector
|
||||||
const blisterOption = page.getByText(/Blister Pack/i);
|
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
||||||
const bottleOption = page.getByText(/Pill Bottle/i);
|
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
||||||
|
|
||||||
if (await blisterOption.isVisible().catch(() => false)) {
|
if (await blisterOption.isVisible().catch(() => false)) {
|
||||||
// Switch to bottle
|
// Switch to bottle
|
||||||
await bottleOption.click();
|
await bottleOption.click();
|
||||||
// Bottle-specific fields should appear
|
// Bottle-specific fields should appear
|
||||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
||||||
|
|
||||||
// Switch back to blister
|
// Switch back to blister
|
||||||
await blisterOption.click();
|
await blisterOption.click();
|
||||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have intake schedule with add button", async ({ page }) => {
|
test("should have intake schedule with add button", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
// Intake schedule section
|
// Intake schedule section
|
||||||
const scheduleSection = page.getByText(/Intake schedule/i);
|
await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
|
||||||
await expect(scheduleSection).toBeVisible();
|
|
||||||
|
|
||||||
// Should have at least one intake entry
|
// Should have at least one intake entry
|
||||||
await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible();
|
await expect(
|
||||||
|
form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first()
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// Should have an add intake button
|
// Should have an add intake button
|
||||||
const addIntake = page.getByRole("button", { name: /Intake/i });
|
const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
|
||||||
await expect(addIntake).toBeVisible();
|
await expect(addIntake).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have save and cancel buttons", async ({ page }) => {
|
test("should have save and cancel buttons", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
// Fill in a name to make the form dirty
|
// Fill in a name to make the form dirty
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Test");
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test");
|
||||||
|
|
||||||
// Save button
|
// Save button
|
||||||
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
||||||
@@ -127,9 +140,10 @@ test.describe("Medications Page", () => {
|
|||||||
|
|
||||||
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
||||||
await openMedicationForm(page);
|
await openMedicationForm(page);
|
||||||
|
const form = visibleMedForm(page);
|
||||||
|
|
||||||
// Fill in the form to create unsaved changes
|
// Fill in the form to create unsaved changes
|
||||||
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication");
|
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication");
|
||||||
|
|
||||||
// Try to navigate away
|
// Try to navigate away
|
||||||
await page.locator('button.pill:has-text("Dashboard")').click();
|
await page.locator('button.pill:has-text("Dashboard")').click();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
authFile,
|
authFile,
|
||||||
createMedicationViaAPI,
|
createMedicationViaAPI,
|
||||||
deleteAllMedicationsViaAPI,
|
deleteAllMedicationsViaAPI,
|
||||||
deleteMedicationViaAPI,
|
|
||||||
expect,
|
expect,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
type TestMedication,
|
type TestMedication,
|
||||||
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
|
|||||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await takeBtn.click();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { authFile, navigateTo, test } from "./fixtures";
|
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule / Timeline E2E Tests
|
* Schedule / Timeline E2E Tests
|
||||||
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
|
|||||||
test.describe("Schedule Timeline", () => {
|
test.describe("Schedule Timeline", () => {
|
||||||
test.use({ storageState: authFile });
|
test.use({ storageState: authFile });
|
||||||
|
|
||||||
|
const seededName = "Schedule Smoke Seed";
|
||||||
|
const startThreeDaysAgo = (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 3);
|
||||||
|
d.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: seededName,
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 2,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
});
|
||||||
|
|
||||||
test("should have timeline container in DOM", async ({ page }) => {
|
test("should have timeline container in DOM", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show past days toggle when medications exist", async ({ page }) => {
|
test("should show past days toggle when medications exist", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Past days toggle only appears when there are scheduled medications
|
// Past days toggle appears when there are scheduled medications
|
||||||
const pastToggle = page.locator(".past-days-toggle");
|
const pastToggle = page.locator(".past-days-toggle");
|
||||||
const hasPastToggle = await pastToggle.isVisible().catch(() => false);
|
await expect(pastToggle).toBeVisible();
|
||||||
|
|
||||||
// Just verify it doesn't crash — visibility depends on medication data
|
|
||||||
expect(typeof hasPastToggle).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should expand/collapse past days on click", async ({ page }) => {
|
test("should expand/collapse past days on click", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const pastToggle = page.locator(".past-days-toggle");
|
const pastToggle = page.locator(".past-days-toggle");
|
||||||
if (!(await pastToggle.isVisible().catch(() => false))) {
|
await expect(pastToggle).toBeVisible();
|
||||||
// No medications — past days toggle not shown
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
||||||
|
|
||||||
@@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => {
|
|||||||
test("should show future days toggle when medications exist", async ({ page }) => {
|
test("should show future days toggle when medications exist", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// Future days toggle only appears when there are scheduled medications
|
// Future days toggle appears when there are scheduled medications
|
||||||
const futureToggle = page.locator(".future-days-toggle");
|
const futureToggle = page.locator(".future-days-toggle");
|
||||||
const hasFutureToggle = await futureToggle.isVisible().catch(() => false);
|
await expect(futureToggle).toBeVisible();
|
||||||
expect(typeof hasFutureToggle).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display day blocks in timeline", async ({ page }) => {
|
test("should display day blocks in timeline", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// There should be at least one day block (today)
|
// With medications there should be day blocks; otherwise empty-state is expected.
|
||||||
const dayBlocks = page.locator(".day-block");
|
const dayBlocks = page.locator(".day-block");
|
||||||
expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0);
|
const dayBlockCount = await dayBlocks.count();
|
||||||
|
if (dayBlockCount === 0) {
|
||||||
|
await expect(page.getByText(/No medications/i)).toBeVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(dayBlockCount).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should highlight today block", async ({ page }) => {
|
test("should highlight today block", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
// If there are medications, today should be highlighted
|
// With medications, today should be highlighted
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
const hasTodayBlock = await todayBlock.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
// Today block exists only if there are medications with schedules
|
|
||||||
if (hasTodayBlock) {
|
|
||||||
await expect(todayBlock).toBeVisible();
|
await expect(todayBlock).toBeVisible();
|
||||||
// Should have a day divider with date text
|
|
||||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show day summary with progress", async ({ page }) => {
|
test("should show day summary with progress", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
if (await todayBlock.isVisible().catch(() => false)) {
|
await expect(todayBlock).toBeVisible();
|
||||||
const summary = todayBlock.locator(".day-summary");
|
const summary = todayBlock.locator(".day-summary");
|
||||||
await expect(summary).toBeVisible();
|
await expect(summary).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should collapse/expand a day block", async ({ page }) => {
|
test("should collapse/expand a day block", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
const todayBlock = page.locator(".day-block.today");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
if (await todayBlock.isVisible().catch(() => false)) {
|
await expect(todayBlock).toBeVisible();
|
||||||
const dayDivider = todayBlock.locator(".day-divider");
|
const dayDivider = todayBlock.locator(".day-divider");
|
||||||
await dayDivider.click();
|
await dayDivider.click();
|
||||||
|
|
||||||
// Check if it toggled collapsed state
|
|
||||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||||
|
|
||||||
// Click again to restore
|
|
||||||
await dayDivider.click();
|
await dayDivider.click();
|
||||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||||
|
|
||||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show overview table with stock status", async ({ page }) => {
|
test("should show overview table with stock status", async ({ page }) => {
|
||||||
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
|
|||||||
|
|
||||||
// Overview table has class .table.table-7
|
// Overview table has class .table.table-7
|
||||||
const overviewTable = page.locator(".table.table-7");
|
const overviewTable = page.locator(".table.table-7");
|
||||||
const hasTable = await overviewTable.isVisible().catch(() => false);
|
await expect(overviewTable).toBeVisible();
|
||||||
|
|
||||||
// Table only visible if medications exist
|
|
||||||
if (hasTable) {
|
|
||||||
// Table should have a header row
|
|
||||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should display share button in schedules section", async ({ page }) => {
|
test("should display share button in schedules section", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
await navigateTo(page, "/dashboard");
|
||||||
|
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
||||||
|
|
||||||
const shareBtn = page.locator("button.share-btn");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
// Share button only visible if there are takenBy users
|
await expect(shareBtn).toBeVisible();
|
||||||
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
// Just verify it's either visible or not (no crash)
|
|
||||||
expect(typeof hasShareBtn).toBe("boolean");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabledToggle) {
|
test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
|
||||||
// All toggles disabled (no notification channels configured) — skip
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
||||||
const initialState = await checkbox.isChecked();
|
const initialState = await checkbox.isChecked();
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
// Should show the shared schedule page (not the login page)
|
// Should show the shared schedule page (not the login page)
|
||||||
// Wait for either the schedule content or an error
|
// Wait for either the schedule content or an error
|
||||||
const sharedContent = page.locator(".shared-schedule, .share-page");
|
const _sharedContent = page.locator(".shared-schedule, .share-page");
|
||||||
const dayBlock = page.locator(".day-block");
|
const dayBlock = page.locator(".day-block");
|
||||||
const medName = page.getByText(MED_ALICE);
|
const medName = page.getByText(MED_ALICE);
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,17 @@
|
|||||||
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
case "${LOG_LEVEL:-info}" in
|
# Normalize: lowercase + trim whitespace
|
||||||
|
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||||
|
|
||||||
|
case "$level" in
|
||||||
warn|error|fatal|silent)
|
warn|error|fatal|silent)
|
||||||
export NGINX_ACCESS_LOG="off"
|
export NGINX_ACCESS_LOG="off"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
export NGINX_ACCESS_LOG="/dev/stdout"
|
export NGINX_ACCESS_LOG="/dev/stdout"
|
||||||
|
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|||||||
@@ -43,5 +43,9 @@ server {
|
|||||||
# Timeout for uploads
|
# Timeout for uploads
|
||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
|
|
||||||
|
# Prevent buffering upstream responses to temp files (images can be large)
|
||||||
|
# nginx streams directly to client instead of buffering the full response
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+149
-110
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.10.3",
|
"version": "1.14.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.10.3",
|
"version": "1.14.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.7",
|
"i18next": "^25.8.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"lucide-react": "^0.574.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
@@ -17,17 +18,18 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
@@ -48,23 +50,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
|
||||||
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
|
"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-calc": "^2.1.4",
|
"@csstools/css-calc": "^3.0.0",
|
||||||
"@csstools/css-color-parser": "^3.1.0",
|
"@csstools/css-color-parser": "^4.0.1",
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4",
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -72,9 +74,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector": {
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
"version": "6.7.6",
|
"version": "6.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||||
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
|
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -82,13 +84,13 @@
|
|||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -404,9 +406,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
|
||||||
"integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==",
|
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -420,20 +422,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.3.15",
|
"@biomejs/cli-darwin-arm64": "2.4.1",
|
||||||
"@biomejs/cli-darwin-x64": "2.3.15",
|
"@biomejs/cli-darwin-x64": "2.4.1",
|
||||||
"@biomejs/cli-linux-arm64": "2.3.15",
|
"@biomejs/cli-linux-arm64": "2.4.1",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.3.15",
|
"@biomejs/cli-linux-arm64-musl": "2.4.1",
|
||||||
"@biomejs/cli-linux-x64": "2.3.15",
|
"@biomejs/cli-linux-x64": "2.4.1",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.3.15",
|
"@biomejs/cli-linux-x64-musl": "2.4.1",
|
||||||
"@biomejs/cli-win32-arm64": "2.3.15",
|
"@biomejs/cli-win32-arm64": "2.4.1",
|
||||||
"@biomejs/cli-win32-x64": "2.3.15"
|
"@biomejs/cli-win32-x64": "2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
|
||||||
"integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==",
|
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -448,9 +450,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
|
||||||
"integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==",
|
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -465,9 +467,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
|
||||||
"integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==",
|
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -482,9 +484,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
|
||||||
"integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==",
|
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -499,9 +501,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
|
||||||
"integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==",
|
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -516,9 +518,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
|
||||||
"integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==",
|
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -533,9 +535,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
|
||||||
"integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==",
|
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -550,9 +552,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.3.15",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
|
||||||
"integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==",
|
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -566,10 +568,23 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
|
||||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
"integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -583,13 +598,13 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-calc": {
|
"node_modules/@csstools/css-calc": {
|
||||||
"version": "2.1.4",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -603,17 +618,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-color-parser": {
|
"node_modules/@csstools/css-color-parser": {
|
||||||
"version": "3.1.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
|
||||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
"integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -627,21 +642,21 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/color-helpers": "^5.1.0",
|
"@csstools/color-helpers": "^6.0.1",
|
||||||
"@csstools/css-calc": "^2.1.4"
|
"@csstools/css-calc": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-parser-algorithms": {
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
"version": "3.0.5",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -655,16 +670,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
"version": "1.0.25",
|
"version": "1.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
|
||||||
"integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
|
"integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -676,15 +691,12 @@
|
|||||||
"url": "https://opencollective.com/csstools"
|
"url": "https://opencollective.com/csstools"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT-0",
|
"license": "MIT-0"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@csstools/css-tokenizer": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
"version": "3.0.4",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -698,7 +710,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -1724,6 +1736,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
|
||||||
|
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -2139,25 +2161,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle": {
|
"node_modules/cssstyle": {
|
||||||
"version": "5.3.7",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
|
||||||
"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
|
"integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^4.1.1",
|
"@asamuzakjp/css-color": "^4.1.2",
|
||||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
|
"@csstools/css-syntax-patches-for-csstree": "^1.0.26",
|
||||||
"css-tree": "^3.1.0",
|
"css-tree": "^3.1.0",
|
||||||
"lru-cache": "^11.2.4"
|
"lru-cache": "^11.2.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||||
"version": "11.2.4",
|
"version": "11.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2438,9 +2460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.8.7",
|
"version": "25.8.10",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
|
||||||
"integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==",
|
"integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -2540,16 +2562,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "28.0.0",
|
"version": "28.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@acemir/cssom": "^0.9.31",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@exodus/bytes": "^1.11.0",
|
"@exodus/bytes": "^1.11.0",
|
||||||
"cssstyle": "^5.3.7",
|
"cssstyle": "^6.0.1",
|
||||||
"data-urls": "^7.0.0",
|
"data-urls": "^7.0.0",
|
||||||
"decimal.js": "^10.6.0",
|
"decimal.js": "^10.6.0",
|
||||||
"html-encoding-sniffer": "^6.0.0",
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
@@ -2560,7 +2583,7 @@
|
|||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"undici": "^7.20.0",
|
"undici": "^7.21.0",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^8.0.1",
|
"webidl-conversions": "^8.0.1",
|
||||||
"whatwg-mimetype": "^5.0.0",
|
"whatwg-mimetype": "^5.0.0",
|
||||||
@@ -2627,6 +2650,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.574.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz",
|
||||||
|
"integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -3281,6 +3313,13 @@
|
|||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
+14
-8
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.11.1",
|
"version": "1.14.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,15 +14,20 @@
|
|||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm",
|
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
||||||
|
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
|
||||||
|
"test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui",
|
||||||
|
"test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed",
|
||||||
|
"test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug",
|
||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^25.8.7",
|
"i18next": "^25.8.10",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
|
"lucide-react": "^0.574.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
@@ -30,17 +35,18 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.15",
|
"@biomejs/biome": "^2.4.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^18.3.4",
|
"@types/react": "^18.3.4",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"jsdom": "^28.0.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
|
export default buildPlaywrightConfig(true);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
||||||
|
const env =
|
||||||
|
typeof globalThis === "object" && "process" in globalThis
|
||||||
|
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
||||||
|
: {};
|
||||||
|
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
|
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||||
|
{
|
||||||
|
name: "setup",
|
||||||
|
testMatch: /.*\.setup\.ts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
retries: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chromium-data",
|
||||||
|
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
dependencies: ["setup"],
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (runAllBrowsers) {
|
||||||
|
projects.push(
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Firefox"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
},
|
||||||
|
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||||
|
dependencies: ["setup"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
testMatch: "**/*.spec.ts",
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!env.CI,
|
||||||
|
retries: env.CI ? 2 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: env.CI
|
||||||
|
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||||
|
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "on",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
navigationTimeout: 30000,
|
||||||
|
actionTimeout: 5000,
|
||||||
|
},
|
||||||
|
projects,
|
||||||
|
outputDir: "test-results/",
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: "cd ../backend && npm run dev",
|
||||||
|
url: "http://localhost:3000/health",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "npm run dev",
|
||||||
|
url: "http://localhost:5173",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,153 +1,3 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
/**
|
export default buildPlaywrightConfig(false);
|
||||||
* Playwright E2E Testing Configuration
|
|
||||||
*
|
|
||||||
* Run E2E tests with:
|
|
||||||
* npm run test:e2e - Run tests in headless mode
|
|
||||||
* npm run test:e2e:ui - Run tests with Playwright UI
|
|
||||||
* npm run test:e2e:headed - Run tests in headed mode
|
|
||||||
*
|
|
||||||
* Before running tests, ensure both backend and frontend are running:
|
|
||||||
* docker compose -f docker-compose.dev.yml up
|
|
||||||
*
|
|
||||||
* Or run them separately:
|
|
||||||
* cd backend && npm run dev
|
|
||||||
* cd frontend && npm run dev
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Base URL for the frontend dev server
|
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
// Directory containing test files
|
|
||||||
testDir: "./e2e",
|
|
||||||
|
|
||||||
// Test file pattern
|
|
||||||
testMatch: "**/*.spec.ts",
|
|
||||||
|
|
||||||
// Maximum time one test can run
|
|
||||||
timeout: 30 * 1000,
|
|
||||||
|
|
||||||
// Maximum time to wait for expect assertions
|
|
||||||
expect: {
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Run tests in parallel
|
|
||||||
fullyParallel: true,
|
|
||||||
|
|
||||||
// Fail the build on CI if you accidentally left test.only in the source code
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
|
|
||||||
// Retry failed tests (more retries on CI)
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
|
|
||||||
// Opt out of parallel tests on CI
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
|
|
||||||
// Reporter configuration
|
|
||||||
reporter: process.env.CI
|
|
||||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
|
||||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
|
||||||
|
|
||||||
// Shared settings for all projects
|
|
||||||
use: {
|
|
||||||
// Base URL for page.goto() calls
|
|
||||||
baseURL,
|
|
||||||
|
|
||||||
// Collect trace on first retry
|
|
||||||
trace: "on-first-retry",
|
|
||||||
|
|
||||||
// Capture screenshot on failure
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
|
|
||||||
// Record video for every test so runs can be reviewed
|
|
||||||
video: "on",
|
|
||||||
|
|
||||||
// Default viewport size
|
|
||||||
viewport: { width: 1280, height: 720 },
|
|
||||||
|
|
||||||
// Wait for network idle before considering navigation complete
|
|
||||||
navigationTimeout: 30000,
|
|
||||||
|
|
||||||
// Accept cookies and local storage
|
|
||||||
actionTimeout: 5000,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Configure projects for multiple browsers
|
|
||||||
projects: [
|
|
||||||
// Setup project for authentication state
|
|
||||||
{
|
|
||||||
name: "setup",
|
|
||||||
testMatch: /.*\.setup\.ts/,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Chrome — primary test browser, always runs
|
|
||||||
// Excludes data/crud tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
retries: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Firefox — runs locally and optionally in CI
|
|
||||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "firefox",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Firefox"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Desktop Safari — runs locally and optionally in CI
|
|
||||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
|
||||||
{
|
|
||||||
name: "webkit",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
dependencies: ["setup"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data tests — only Chromium, run serially to avoid DB conflicts
|
|
||||||
// These tests create/edit/delete medications and must not run concurrently
|
|
||||||
// across browsers since all share the same backend database.
|
|
||||||
{
|
|
||||||
name: "chromium-data",
|
|
||||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
dependencies: ["setup"],
|
|
||||||
fullyParallel: false,
|
|
||||||
retries: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// Directory for test output files (screenshots, traces, videos)
|
|
||||||
outputDir: "test-results/",
|
|
||||||
|
|
||||||
// Web server configuration — automatically start dev servers in CI
|
|
||||||
webServer: [
|
|
||||||
{
|
|
||||||
command: "cd ../backend && npm run dev",
|
|
||||||
url: "http://localhost:3000/health",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: "npm run dev",
|
|
||||||
url: "http://localhost:5173",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120 * 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||||
|
|
||||||
|
export default buildPlaywrightConfig(false);
|
||||||
+54
-30
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AboutModal,
|
AboutModal,
|
||||||
Lightbox,
|
Lightbox,
|
||||||
@@ -112,14 +112,13 @@ function AppRouter() {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
// Get shared state from AppContext
|
// Get shared state from AppContext
|
||||||
const ctx = useAppContext();
|
const ctx = useAppContext();
|
||||||
const {
|
const {
|
||||||
// Medications
|
// Medications
|
||||||
meds,
|
meds,
|
||||||
loadMeds,
|
loadMeds,
|
||||||
// Settings
|
|
||||||
settings,
|
|
||||||
// Refill
|
// Refill
|
||||||
showRefillModal,
|
showRefillModal,
|
||||||
setShowRefillModal,
|
setShowRefillModal,
|
||||||
@@ -139,7 +138,10 @@ function AppContent() {
|
|||||||
setEditStockFullBlisters,
|
setEditStockFullBlisters,
|
||||||
editStockPartialBlisterPills,
|
editStockPartialBlisterPills,
|
||||||
setEditStockPartialBlisterPills,
|
setEditStockPartialBlisterPills,
|
||||||
|
editStockLoosePills,
|
||||||
|
setEditStockLoosePills,
|
||||||
editStockSaving,
|
editStockSaving,
|
||||||
|
editStockMedication,
|
||||||
openRefillModal,
|
openRefillModal,
|
||||||
closeRefillModal,
|
closeRefillModal,
|
||||||
openEditStockModal,
|
openEditStockModal,
|
||||||
@@ -186,6 +188,17 @@ function AppContent() {
|
|||||||
// Local-only state (not shared across components)
|
// Local-only state (not shared across components)
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const closeProfile = useCallback(() => {
|
||||||
|
if (showProfile) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showProfile]);
|
||||||
|
|
||||||
|
const closeAbout = useCallback(() => {
|
||||||
|
if (showAbout) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}, [showAbout]);
|
||||||
|
|
||||||
// Get centralized stockThresholds from context
|
// Get centralized stockThresholds from context
|
||||||
const { stockThresholds } = ctx;
|
const { stockThresholds } = ctx;
|
||||||
@@ -289,18 +302,25 @@ function AppContent() {
|
|||||||
// Close tooltips on scroll/touch (for mobile)
|
// Close tooltips on scroll/touch (for mobile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closeAllTooltips = () => {
|
const closeAllTooltips = () => {
|
||||||
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => {
|
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
||||||
el.classList.remove("tooltip-active");
|
el.classList.remove("tooltip-active");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTooltipClick = (e: Event) => {
|
const handleTooltipClick = (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.classList.contains("info-tooltip")) {
|
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
||||||
|
if (tooltipTrigger) {
|
||||||
// Close other tooltips first
|
// Close other tooltips first
|
||||||
closeAllTooltips();
|
closeAllTooltips();
|
||||||
// Toggle this one
|
// Toggle this one
|
||||||
target.classList.add("tooltip-active");
|
tooltipTrigger.classList.add("tooltip-active");
|
||||||
|
// Position tooltip above the icon on mobile
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
const rect = tooltipTrigger.getBoundingClientRect();
|
||||||
|
// Place tooltip bottom edge just above the icon
|
||||||
|
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
closeAllTooltips();
|
closeAllTooltips();
|
||||||
}
|
}
|
||||||
@@ -351,9 +371,11 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [meds, selectedMed, setSelectedMed]);
|
}, [meds, selectedMed, setSelectedMed]);
|
||||||
|
|
||||||
|
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
|
||||||
|
|
||||||
const handleSubmitStockCorrection = async (medId: number) => {
|
const handleSubmitStockCorrection = async (medId: number) => {
|
||||||
if (!selectedMed) return;
|
if (!stockCorrectionMed) return;
|
||||||
await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
|
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
|
||||||
};
|
};
|
||||||
|
|
||||||
// For MedDetailModal: refill without form update (not editing)
|
// For MedDetailModal: refill without form update (not editing)
|
||||||
@@ -361,32 +383,30 @@ function AppContent() {
|
|||||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
const handleOpenMedicationEdit = () => {
|
||||||
const handleOpenEditStockModal = () => {
|
if (!selectedMed) return;
|
||||||
if (selectedMed) {
|
const medId = selectedMed.id;
|
||||||
openEditStockModal(selectedMed, coverage);
|
setShowImageLightbox(false);
|
||||||
}
|
setShowRefillModal(false);
|
||||||
|
setShowEditStockModal(false);
|
||||||
|
setSelectedMed(null);
|
||||||
|
navigate(`/medications?editMedId=${medId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
function openProfile() {
|
const handleOpenEditStockFromDetail = () => {
|
||||||
|
if (!selectedMed) return;
|
||||||
|
openEditStockModal(selectedMed, coverage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProfile = useCallback(() => {
|
||||||
setShowProfile(true);
|
setShowProfile(true);
|
||||||
window.history.pushState({ modal: "profile" }, "");
|
window.history.pushState({ modal: "profile" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeProfile() {
|
|
||||||
if (showProfile) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAbout() {
|
const openAbout = useCallback(() => {
|
||||||
setShowAbout(true);
|
setShowAbout(true);
|
||||||
window.history.pushState({ modal: "about" }, "");
|
window.history.pushState({ modal: "about" }, "");
|
||||||
}
|
}, []);
|
||||||
function closeAbout() {
|
|
||||||
if (showAbout) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
@@ -415,18 +435,20 @@ function AppContent() {
|
|||||||
|
|
||||||
{/* Medication Detail Modal */}
|
{/* Medication Detail Modal */}
|
||||||
<MedDetailModal
|
<MedDetailModal
|
||||||
selectedMed={selectedMed}
|
selectedMed={stockCorrectionMed}
|
||||||
coverage={coverage}
|
coverage={coverage}
|
||||||
settings={stockThresholds}
|
settings={stockThresholds}
|
||||||
showImageLightbox={showImageLightbox}
|
showImageLightbox={showImageLightbox}
|
||||||
showRefillModal={showRefillModal}
|
showRefillModal={showRefillModal}
|
||||||
showEditStockModal={showEditStockModal}
|
showEditStockModal={showEditStockModal}
|
||||||
|
editStockOnly={showEditStockModal && !selectedMed}
|
||||||
onClose={closeMedDetail}
|
onClose={closeMedDetail}
|
||||||
onOpenImageLightbox={openImageLightbox}
|
onOpenImageLightbox={openImageLightbox}
|
||||||
onCloseImageLightbox={closeImageLightbox}
|
onCloseImageLightbox={closeImageLightbox}
|
||||||
onOpenRefillModal={openRefillModal}
|
onOpenRefillModal={openRefillModal}
|
||||||
onCloseRefillModal={closeRefillModal}
|
onCloseRefillModal={closeRefillModal}
|
||||||
onOpenEditStockModal={handleOpenEditStockModal}
|
onOpenMedicationEdit={handleOpenMedicationEdit}
|
||||||
|
onOpenEditStockModal={handleOpenEditStockFromDetail}
|
||||||
onCloseEditStockModal={closeEditStockModal}
|
onCloseEditStockModal={closeEditStockModal}
|
||||||
refillPacks={refillPacks}
|
refillPacks={refillPacks}
|
||||||
onRefillPacksChange={setRefillPacks}
|
onRefillPacksChange={setRefillPacks}
|
||||||
@@ -443,6 +465,8 @@ function AppContent() {
|
|||||||
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
||||||
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
||||||
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
||||||
|
editStockLoosePills={editStockLoosePills}
|
||||||
|
onEditStockLoosePillsChange={setEditStockLoosePills}
|
||||||
editStockSaving={editStockSaving}
|
editStockSaving={editStockSaving}
|
||||||
onSubmitStockCorrection={handleSubmitStockCorrection}
|
onSubmitStockCorrection={handleSubmitStockCorrection}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,8 +51,18 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content about-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useUnsavedChanges } from "../context";
|
import { useUnsavedChanges } from "../context";
|
||||||
import type { ThemePreference } from "../hooks";
|
|
||||||
import { useTheme } from "../hooks";
|
import { useTheme } from "../hooks";
|
||||||
import { useAuth } from "./Auth";
|
import { useAuth } from "./Auth";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
|
||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
@@ -70,7 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
initialFetchDone.current = true;
|
initialFetchDone.current = true;
|
||||||
fetchAuthState();
|
fetchAuthState();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [fetchAuthState]);
|
||||||
|
|
||||||
// Proactively refresh token every 10 minutes to prevent expiration
|
// Proactively refresh token every 10 minutes to prevent expiration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => clearInterval(refreshInterval);
|
return () => clearInterval(refreshInterval);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, authState?.authEnabled]);
|
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
|
||||||
|
|
||||||
async function fetchAuthState(retryCount = 0) {
|
async function fetchAuthState(retryCount = 0) {
|
||||||
const maxRetries = 3;
|
const maxRetries = 3;
|
||||||
@@ -756,7 +757,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="profile-actions">
|
<div className="profile-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
{t("common.cancel", "Cancel")}
|
{t("common.close", "Close")}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface ConfirmModalProps {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
confirmVariant?: "primary" | "danger" | "success";
|
confirmVariant?: "primary" | "danger" | "success" | "warning";
|
||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,19 @@ export function ConfirmModal({
|
|||||||
}, [onCancel]);
|
}, [onCancel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
<div
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||||
|
onClick={onCancel}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content confirm-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: "450px" }}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onCancel}>
|
<button className="modal-close" onClick={onCancel}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export function DateInput({ value, placeholder, className, ...rest }: DateInputP
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
<div
|
||||||
|
className={`date-input-wrapper ${className ?? ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="date-input-display" aria-hidden="true">
|
<span className="date-input-display" aria-hidden="true">
|
||||||
{displayValue || placeholder || ""}
|
{displayValue || placeholder || ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export function DateTimeInput({ value, placeholder, className, ...rest }: DateTi
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
<div
|
||||||
|
className={`date-input-wrapper ${className ?? ""}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="date-input-display" aria-hidden="true">
|
<span className="date-input-display" aria-hidden="true">
|
||||||
{displayValue || placeholder || ""}
|
{displayValue || placeholder || ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,8 +13,19 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
style={{ maxWidth: "450px" }}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -53,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{t("exportImport.cancelButton")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,12 +19,27 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
<div
|
||||||
|
className="lightbox-overlay"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="lightbox-container">
|
<div className="lightbox-container">
|
||||||
<button className="lightbox-close" onClick={onClose}>
|
<button className="lightbox-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="lightbox-image"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
|||||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||||
* Handles new medication creation and editing existing medications
|
* Handles new medication creation and editing existing medications
|
||||||
*/
|
*/
|
||||||
import { useEffect } from "react";
|
|
||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
|
||||||
|
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||||
import { DOSE_UNITS } from "../types";
|
import { DOSE_UNITS } from "../types";
|
||||||
@@ -17,6 +20,9 @@ const FIELD_LIMITS = {
|
|||||||
notes: { max: 1000 },
|
notes: { max: 1000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MOBILE_TAB_ORDER = ["general", "stock", "schedule", "prescription"] as const;
|
||||||
|
type MobileTab = (typeof MOBILE_TAB_ORDER)[number];
|
||||||
|
|
||||||
export interface MobileEditModalProps {
|
export interface MobileEditModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
editingId: number | null;
|
editingId: number | null;
|
||||||
@@ -46,15 +52,6 @@ export interface MobileEditModalProps {
|
|||||||
onRemoveIntake: (idx: number) => void;
|
onRemoveIntake: (idx: number) => void;
|
||||||
// Value change handler for numeric fields
|
// Value change handler for numeric fields
|
||||||
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
onHandleValueChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
|
||||||
// Refill state (for edit mode)
|
|
||||||
refillPacks: number;
|
|
||||||
onRefillPacksChange: (value: number) => void;
|
|
||||||
refillLoose: number;
|
|
||||||
onRefillLooseChange: (value: number) => void;
|
|
||||||
usePrescriptionRefill: boolean;
|
|
||||||
onUsePrescriptionRefillChange: (value: boolean) => void;
|
|
||||||
refillSaving: boolean;
|
|
||||||
onSubmitRefill: (medId: number) => Promise<void>;
|
|
||||||
// Image handling
|
// Image handling
|
||||||
meds: Medication[];
|
meds: Medication[];
|
||||||
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
onUploadMedImage: (medId: number, file: File) => Promise<void>;
|
||||||
@@ -74,8 +71,7 @@ function deriveTotalFromForm(form: FormState) {
|
|||||||
const packCount = Number(form.packCount) || 0;
|
const packCount = Number(form.packCount) || 0;
|
||||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
const looseTablets = Number(form.looseTablets) || 0;
|
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, 0);
|
||||||
return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileEditModal({
|
export function MobileEditModal({
|
||||||
@@ -96,21 +92,13 @@ export function MobileEditModal({
|
|||||||
onAddTakenByPerson,
|
onAddTakenByPerson,
|
||||||
onRemoveTakenByPerson,
|
onRemoveTakenByPerson,
|
||||||
onTakenByKeyDown,
|
onTakenByKeyDown,
|
||||||
onSetBlisterValue,
|
_onSetBlisterValue,
|
||||||
onAddBlister,
|
_onAddBlister,
|
||||||
onRemoveBlister,
|
_onRemoveBlister,
|
||||||
onSetIntakeValue,
|
onSetIntakeValue,
|
||||||
onAddIntake,
|
onAddIntake,
|
||||||
onRemoveIntake,
|
onRemoveIntake,
|
||||||
onHandleValueChange,
|
onHandleValueChange,
|
||||||
refillPacks,
|
|
||||||
onRefillPacksChange,
|
|
||||||
refillLoose,
|
|
||||||
onRefillLooseChange,
|
|
||||||
usePrescriptionRefill,
|
|
||||||
onUsePrescriptionRefillChange,
|
|
||||||
refillSaving,
|
|
||||||
onSubmitRefill,
|
|
||||||
meds,
|
meds,
|
||||||
onUploadMedImage,
|
onUploadMedImage,
|
||||||
onDeleteMedImage,
|
onDeleteMedImage,
|
||||||
@@ -119,6 +107,20 @@ export function MobileEditModal({
|
|||||||
onSaveMedication,
|
onSaveMedication,
|
||||||
}: MobileEditModalProps) {
|
}: MobileEditModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState<MobileTab>("general");
|
||||||
|
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
||||||
|
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const tabViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const swipeAxisRef = useRef<"x" | "y" | null>(null);
|
||||||
|
const [swipeDeltaX, setSwipeDeltaX] = useState(0);
|
||||||
|
const [isHorizontalSwiping, setIsHorizontalSwiping] = useState(false);
|
||||||
|
const activeTabIndexRef = useRef(0);
|
||||||
|
|
||||||
|
// Reset tab when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) setActiveTab("general");
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
// Close on Escape key
|
// Close on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,21 +134,195 @@ export function MobileEditModal({
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [show, onClose]);
|
}, [show, onClose]);
|
||||||
|
|
||||||
|
// Lock background scroll while modal is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return;
|
||||||
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
|
const hadHtmlModalClass = html.classList.contains("modal-open");
|
||||||
|
const hadBodyModalClass = body.classList.contains("modal-open");
|
||||||
|
|
||||||
|
const previousHtmlOverflow = html.style.overflow;
|
||||||
|
const previousHtmlOverscrollBehavior = html.style.overscrollBehavior;
|
||||||
|
const previousBodyOverflow = body.style.overflow;
|
||||||
|
const previousBodyPosition = body.style.position;
|
||||||
|
const previousBodyTop = body.style.top;
|
||||||
|
const previousBodyLeft = body.style.left;
|
||||||
|
const previousBodyRight = body.style.right;
|
||||||
|
const previousBodyWidth = body.style.width;
|
||||||
|
const previousBodyOverscrollBehavior = body.style.overscrollBehavior;
|
||||||
|
|
||||||
|
html.classList.add("modal-open");
|
||||||
|
body.classList.add("modal-open");
|
||||||
|
html.style.overflow = "hidden";
|
||||||
|
html.style.overscrollBehavior = "none";
|
||||||
|
body.style.overflow = "hidden";
|
||||||
|
body.style.position = "fixed";
|
||||||
|
body.style.top = `-${scrollY}px`;
|
||||||
|
body.style.left = "0";
|
||||||
|
body.style.right = "0";
|
||||||
|
body.style.width = "100%";
|
||||||
|
body.style.overscrollBehavior = "none";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!hadHtmlModalClass) html.classList.remove("modal-open");
|
||||||
|
if (!hadBodyModalClass) body.classList.remove("modal-open");
|
||||||
|
|
||||||
|
html.style.overflow = previousHtmlOverflow;
|
||||||
|
html.style.overscrollBehavior = previousHtmlOverscrollBehavior;
|
||||||
|
body.style.overflow = previousBodyOverflow;
|
||||||
|
body.style.position = previousBodyPosition;
|
||||||
|
body.style.top = previousBodyTop;
|
||||||
|
body.style.left = previousBodyLeft;
|
||||||
|
body.style.right = previousBodyRight;
|
||||||
|
body.style.width = previousBodyWidth;
|
||||||
|
body.style.overscrollBehavior = previousBodyOverscrollBehavior;
|
||||||
|
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
};
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
// Keep activeTabIndex ref in sync for native listeners
|
||||||
|
const activeTabIndex = MOBILE_TAB_ORDER.indexOf(activeTab);
|
||||||
|
activeTabIndexRef.current = activeTabIndex;
|
||||||
|
|
||||||
|
// Auto-scroll tab strip to keep active tab visible
|
||||||
|
useEffect(() => {
|
||||||
|
const strip = tabStripRef.current;
|
||||||
|
if (!strip) return;
|
||||||
|
const btn = strip.children[activeTabIndex] as HTMLElement | undefined;
|
||||||
|
if (btn) btn.scrollIntoView?.({ behavior: "smooth", inline: "nearest", block: "nearest" });
|
||||||
|
}, [activeTabIndex]);
|
||||||
|
|
||||||
|
// Non-passive touch listeners for reliable horizontal swipe detection.
|
||||||
|
// React's onTouchMove is passive, so e.preventDefault() is a no-op there.
|
||||||
|
// With native { passive: false } we can block the browser's vertical scroll
|
||||||
|
// when a horizontal swipe is detected, making tab swiping reliable.
|
||||||
|
useEffect(() => {
|
||||||
|
const fieldset = fieldsetRef.current;
|
||||||
|
if (!show || !fieldset) return;
|
||||||
|
|
||||||
|
const AXIS_LOCK_THRESHOLD = 6;
|
||||||
|
|
||||||
|
function resetSwipe() {
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
swipeAxisRef.current = null;
|
||||||
|
setIsHorizontalSwiping(false);
|
||||||
|
setSwipeDeltaX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const touch = e.touches[0];
|
||||||
|
swipeStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
swipeAxisRef.current = null;
|
||||||
|
setIsHorizontalSwiping(false);
|
||||||
|
setSwipeDeltaX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!swipeStartRef.current || e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - swipeStartRef.current.x;
|
||||||
|
const dy = touch.clientY - swipeStartRef.current.y;
|
||||||
|
const ax = Math.abs(dx);
|
||||||
|
const ay = Math.abs(dy);
|
||||||
|
|
||||||
|
if (!swipeAxisRef.current) {
|
||||||
|
if (ax < AXIS_LOCK_THRESHOLD && ay < AXIS_LOCK_THRESHOLD) return;
|
||||||
|
swipeAxisRef.current = ax >= ay ? "x" : "y";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swipeAxisRef.current === "y") return;
|
||||||
|
|
||||||
|
// Horizontal swipe — block native vertical scroll
|
||||||
|
e.preventDefault();
|
||||||
|
setIsHorizontalSwiping(true);
|
||||||
|
|
||||||
|
let nextDelta = dx;
|
||||||
|
const idx = activeTabIndexRef.current;
|
||||||
|
if ((idx === 0 && nextDelta > 0) || (idx === MOBILE_TAB_ORDER.length - 1 && nextDelta < 0)) {
|
||||||
|
nextDelta *= 0.35;
|
||||||
|
}
|
||||||
|
setSwipeDeltaX(nextDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd(e: TouchEvent) {
|
||||||
|
if (!swipeStartRef.current || e.changedTouches.length !== 1) {
|
||||||
|
resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swipeAxisRef.current === "x") {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const dx = touch.clientX - swipeStartRef.current.x;
|
||||||
|
const minSwipe = Math.max(36, (tabViewportRef.current?.clientWidth ?? 360) * 0.1);
|
||||||
|
if (Math.abs(dx) >= minSwipe) {
|
||||||
|
const direction = dx < 0 ? 1 : -1;
|
||||||
|
const idx = activeTabIndexRef.current;
|
||||||
|
const next = Math.min(Math.max(idx + direction, 0), MOBILE_TAB_ORDER.length - 1);
|
||||||
|
if (next !== idx) {
|
||||||
|
setActiveTab(MOBILE_TAB_ORDER[next]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetSwipe();
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
fieldset.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||||
|
fieldset.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||||
|
fieldset.addEventListener("touchcancel", resetSwipe, { passive: true });
|
||||||
|
return () => {
|
||||||
|
fieldset.removeEventListener("touchstart", onTouchStart);
|
||||||
|
fieldset.removeEventListener("touchmove", onTouchMove);
|
||||||
|
fieldset.removeEventListener("touchend", onTouchEnd);
|
||||||
|
fieldset.removeEventListener("touchcancel", resetSwipe);
|
||||||
|
};
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||||
|
const mobileTitle = (() => {
|
||||||
|
if (!editingId) return t("form.newEntry");
|
||||||
|
if (readOnlyMode) return t("form.viewEntry");
|
||||||
|
const medicationName = currentMed?.name?.trim() || form.name.trim();
|
||||||
|
if (!medicationName) return t("form.editEntry");
|
||||||
|
return t("form.editEntryWithName", { name: medicationName });
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay mobile-edit-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content edit-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="edit-modal-header">
|
<div className="edit-modal-header">
|
||||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||||
← {t("common.back")}
|
← {t("common.back")}
|
||||||
</button>
|
</button>
|
||||||
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2>
|
<h2>{mobileTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
className="form-grid mobile-edit-form"
|
className="form-grid mobile-edit-form"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
// Check native HTML5 validation first
|
// Check native HTML5 validation first
|
||||||
const formElement = e.currentTarget;
|
const formElement = e.currentTarget;
|
||||||
@@ -159,7 +335,55 @@ export function MobileEditModal({
|
|||||||
onSaveMedication(e);
|
onSaveMedication(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
|
<div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")} ref={tabStripRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "general"}
|
||||||
|
className={`form-tab${activeTab === "general" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("general")}
|
||||||
|
>
|
||||||
|
{t("form.sections.general")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "stock"}
|
||||||
|
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("stock")}
|
||||||
|
>
|
||||||
|
{t("form.sections.stock")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "schedule"}
|
||||||
|
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("schedule")}
|
||||||
|
>
|
||||||
|
{t("form.sections.schedule")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "prescription"}
|
||||||
|
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("prescription")}
|
||||||
|
>
|
||||||
|
{t("form.sections.prescription")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<fieldset
|
||||||
|
ref={fieldsetRef}
|
||||||
|
className={`readonly-fieldset${isHorizontalSwiping ? " swiping-horizontal" : ""}`}
|
||||||
|
disabled={readOnlyMode}
|
||||||
|
>
|
||||||
|
<div className="mobile-tab-viewport" ref={tabViewportRef}>
|
||||||
|
<div
|
||||||
|
className={`mobile-tab-track${isHorizontalSwiping ? " is-swiping" : ""}`}
|
||||||
|
style={{ transform: `translateX(calc(${-activeTabIndex * 100}% + ${swipeDeltaX}px))` }}
|
||||||
|
>
|
||||||
|
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
||||||
@@ -189,7 +413,20 @@ export function MobileEditModal({
|
|||||||
value={form.medicationStartDate}
|
value={form.medicationStartDate}
|
||||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
{!readOnlyMode && dateConsistencyError && (
|
||||||
|
<span className="field-error">{dateConsistencyError}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="full">
|
||||||
|
{t("form.packageType")}
|
||||||
|
<select
|
||||||
|
className="package-type-select"
|
||||||
|
value={form.packageType}
|
||||||
|
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||||
{t("form.takenBy")}
|
{t("form.takenBy")}
|
||||||
@@ -210,7 +447,9 @@ export function MobileEditModal({
|
|||||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
form.takenBy.length === 0
|
||||||
|
? t("form.placeholders.takenBy")
|
||||||
|
: t("form.placeholders.addPerson")
|
||||||
}
|
}
|
||||||
maxLength={FIELD_LIMITS.takenBy.max}
|
maxLength={FIELD_LIMITS.takenBy.max}
|
||||||
list="takenby-suggestions-modal"
|
list="takenby-suggestions-modal"
|
||||||
@@ -225,19 +464,35 @@ export function MobileEditModal({
|
|||||||
</div>
|
</div>
|
||||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label className="full">
|
|
||||||
{t("form.packageType")}
|
|
||||||
<select
|
|
||||||
className="package-type-select"
|
|
||||||
value={form.packageType}
|
|
||||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingId && (
|
||||||
|
<div className="full form-category image-section">
|
||||||
|
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
||||||
|
{currentMed?.imageUrl ? (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => onDeleteMedImage(editingId)}
|
||||||
|
aria-label={t("form.removeImage")}
|
||||||
|
data-tooltip={t("form.removeImage")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{form.packageType === "blister" ? (
|
||||||
@@ -273,14 +528,8 @@ export function MobileEditModal({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.loosePills")}
|
{t("form.total")}
|
||||||
<input
|
<div className="static-value">{deriveTotalFromForm(form)}</div>
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -307,12 +556,16 @@ export function MobileEditModal({
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="full">
|
{form.packageType === "bottle" && (
|
||||||
|
<div className="full stock-total-row">
|
||||||
|
<div className="stock-total-field">
|
||||||
<p className="sub">
|
<p className="sub">
|
||||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("form.pillWeight")} ({form.doseUnit})
|
{t("form.pillWeight")} ({form.doseUnit})
|
||||||
<div className="dose-input-group">
|
<div className="dose-input-group">
|
||||||
@@ -360,26 +613,128 @@ export function MobileEditModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{form.notes.length > 0 && (
|
{form.notes.length > 0 && (
|
||||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
<span
|
||||||
|
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
|
||||||
|
>
|
||||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
|
||||||
|
<div className="full form-category intake-section">
|
||||||
|
<div className="form-category-header">
|
||||||
|
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||||
|
{!readOnlyMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost add-blister icon-only tooltip-trigger"
|
||||||
|
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||||
|
aria-label={t("form.blisters.addIntake")}
|
||||||
|
data-tooltip={t("form.blisters.addIntake")}
|
||||||
|
>
|
||||||
|
<Plus size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{form.intakes.map((intake, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
|
||||||
|
className="blister-row"
|
||||||
|
>
|
||||||
|
<label className="compact">
|
||||||
|
<span>{t("form.blisters.usage")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*\.?[0-9]*"
|
||||||
|
value={intake.usage}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="compact">
|
||||||
|
<span>{t("form.blisters.everyDays")}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={intake.every}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="compact full-row">
|
||||||
|
<span>{t("form.blisters.startDate")}</span>
|
||||||
|
<DateInput
|
||||||
|
value={intake.startDate}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="compact time-label">
|
||||||
|
<span>{t("form.blisters.startTime")}</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={intake.startTime}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{form.takenBy.length === 0 ? null : (
|
||||||
|
<label className="compact full-row taken-by-field">
|
||||||
|
<span>{t("form.blisters.takenByIntake")}</span>
|
||||||
|
<select
|
||||||
|
value={intake.takenBy}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
|
||||||
|
>
|
||||||
|
{form.takenBy.map((person) => (
|
||||||
|
<option key={person} value={person}>
|
||||||
|
{person}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||||
|
<span className="legend-hint">
|
||||||
|
<Bell size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<label className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={intake.intakeRemindersEnabled}
|
||||||
|
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!readOnlyMode && form.intakes.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger remove-blister-btn icon-only tooltip-trigger"
|
||||||
|
onClick={() => onRemoveIntake(idx)}
|
||||||
|
aria-label={t("common.remove")}
|
||||||
|
data-tooltip={t("common.remove")}
|
||||||
|
>
|
||||||
|
<Minus size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
{t("prescription.enabled")}
|
{t("prescription.enabled")}
|
||||||
<label className="toggle-switch small">
|
<span className="toggle-switch small">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.prescriptionEnabled}
|
checked={form.prescriptionEnabled}
|
||||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="toggle-slider"></span>
|
<span className="toggle-slider"></span>
|
||||||
</label>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{form.prescriptionEnabled && (
|
{form.prescriptionEnabled && (
|
||||||
<>
|
<>
|
||||||
@@ -423,199 +778,13 @@ export function MobileEditModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!readOnlyMode && (
|
|
||||||
<div className="full form-category refill-section">
|
|
||||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
|
||||||
{editingId ? (
|
|
||||||
<>
|
|
||||||
{form.packageType === "blister" ? (
|
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
{t("refill.packs")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillPacks}
|
|
||||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("refill.loosePills")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<label className="full">
|
|
||||||
{t("refill.pillsToAdd")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="refill-submit-row full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="success"
|
|
||||||
onClick={() => onSubmitRefill(editingId)}
|
|
||||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
|
||||||
>
|
|
||||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
|
||||||
</button>
|
|
||||||
{(() => {
|
|
||||||
const totalRefill =
|
|
||||||
form.packageType === "blister"
|
|
||||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
|
||||||
refillLoose
|
|
||||||
: refillLoose;
|
|
||||||
return totalRefill > 0 ? (
|
|
||||||
<span className="refill-preview">
|
|
||||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
{form.prescriptionEnabled && (
|
|
||||||
<div className="refill-prescription-row full">
|
|
||||||
<label className="refill-prescription-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={usePrescriptionRefill}
|
|
||||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
|
||||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
|
||||||
/>
|
|
||||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
|
||||||
</label>
|
|
||||||
<span className="refill-remaining-badge">
|
|
||||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="refill-unavailable">
|
|
||||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editingId && (
|
|
||||||
<div className="full form-category image-section">
|
|
||||||
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
|
||||||
{currentMed?.imageUrl ? (
|
|
||||||
<div className="image-preview">
|
|
||||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
|
||||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
|
||||||
{t("form.removeImage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="full form-category intake-section">
|
|
||||||
<div className="form-category-header">
|
|
||||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
|
||||||
{!readOnlyMode && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost add-blister"
|
|
||||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
|
||||||
>
|
|
||||||
+ {t("form.blisters.addIntake")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{form.intakes.map((intake, idx) => (
|
|
||||||
<div key={idx} className="blister-row">
|
|
||||||
<label className="compact">
|
|
||||||
<span>{t("form.blisters.usage")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
pattern="[0-9]*\.?[0-9]*"
|
|
||||||
value={intake.usage}
|
|
||||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="compact">
|
|
||||||
<span>{t("form.blisters.everyDays")}</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={intake.every}
|
|
||||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="compact full-row">
|
|
||||||
<span>{t("form.blisters.startDate")}</span>
|
|
||||||
<DateInput
|
|
||||||
value={intake.startDate}
|
|
||||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="compact time-label">
|
|
||||||
<span>{t("form.blisters.startTime")}</span>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={intake.startTime}
|
|
||||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{form.takenBy.length === 0 ? null : (
|
|
||||||
<label className="compact full-row">
|
|
||||||
<span>{t("form.blisters.takenByIntake")}</span>
|
|
||||||
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
|
||||||
{form.takenBy.map((person) => (
|
|
||||||
<option key={person} value={person}>
|
|
||||||
{person}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
|
||||||
<span className="legend-hint">🔔</span>
|
|
||||||
<label className="toggle-switch small">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={intake.intakeRemindersEnabled}
|
|
||||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span className="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{!readOnlyMode && form.intakes.length > 1 && (
|
|
||||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
|
||||||
{t("common.remove")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{readOnlyMode ? t("common.close") : t("common.cancel")}
|
{readOnlyMode || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{!readOnlyMode && (
|
{!readOnlyMode && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,8 +9,18 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content profile-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Medication } from "../types";
|
||||||
|
import { getPackageSize } from "../types";
|
||||||
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
|
||||||
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
|
|
||||||
|
interface ReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
medications: Medication[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportData = Record<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
|
dosesDismissed: number;
|
||||||
|
firstDoseAt: string | null;
|
||||||
|
lastDoseAt: string | null;
|
||||||
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [format, setFormat] = useState<ReportFormat>("pdf");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Collect all unique "taken by" people across all medications
|
||||||
|
const allPeople = useMemo(() => {
|
||||||
|
const people = new Set<string>();
|
||||||
|
for (const med of medications) {
|
||||||
|
if (med.takenBy) {
|
||||||
|
for (const p of med.takenBy) people.add(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(people).sort();
|
||||||
|
}, [medications]);
|
||||||
|
|
||||||
|
// Filtered medications based on takenBy filter
|
||||||
|
const filteredMeds = useMemo(() => {
|
||||||
|
if (takenByFilter.size === 0) return medications;
|
||||||
|
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
|
||||||
|
}, [medications, takenByFilter]);
|
||||||
|
|
||||||
|
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
|
||||||
|
const obsoleteMeds = useMemo(() => filteredMeds.filter((m) => m.isObsolete), [filteredMeds]);
|
||||||
|
|
||||||
|
const togglePerson = useCallback((person: string) => {
|
||||||
|
setTakenByFilter((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(person)) next.delete(person);
|
||||||
|
else next.add(person);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAllPeople = useCallback(() => {
|
||||||
|
setTakenByFilter(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset selection when modal opens or filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
|
||||||
|
}
|
||||||
|
}, [isOpen, filteredMeds]);
|
||||||
|
|
||||||
|
// Reset everything when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTakenByFilter(new Set());
|
||||||
|
setFormat("pdf");
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const toggleMed = useCallback((id: number) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
setSelectedIds(new Set(filteredMeds.map((m) => m.id)));
|
||||||
|
}, [filteredMeds]);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch report data from backend
|
||||||
|
const res = await fetch("/api/medications/report-data", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||||
|
const reportData = (await res.json()) as ReportData;
|
||||||
|
|
||||||
|
if (format === "pdf") {
|
||||||
|
const imageMap = await fetchMedImages(selectedMeds);
|
||||||
|
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||||
|
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
|
||||||
|
} else {
|
||||||
|
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||||
|
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
|
||||||
|
downloadFile(content, format);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Stay open on error so user can retry
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content report-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button className="modal-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h2 className="report-modal-title">{t("report.title")}</h2>
|
||||||
|
<p className="report-modal-desc">{t("report.description")}</p>
|
||||||
|
|
||||||
|
{/* Person filter */}
|
||||||
|
{allPeople.length > 1 && (
|
||||||
|
<div className="report-person-filter">
|
||||||
|
<h4>{t("report.filterByPerson")}</h4>
|
||||||
|
<div className="report-format-options">
|
||||||
|
<label className={`report-format-option${takenByFilter.size === 0 ? " selected" : ""}`}>
|
||||||
|
<input type="checkbox" checked={takenByFilter.size === 0} onChange={selectAllPeople} />
|
||||||
|
<span>{t("report.allPeople")}</span>
|
||||||
|
</label>
|
||||||
|
{allPeople.map((person) => (
|
||||||
|
<label key={person} className={`report-format-option${takenByFilter.has(person) ? " selected" : ""}`}>
|
||||||
|
<input type="checkbox" checked={takenByFilter.has(person)} onChange={() => togglePerson(person)} />
|
||||||
|
<span>{person}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Medication selection */}
|
||||||
|
<div className="report-selection">
|
||||||
|
<div className="report-selection-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={selectedIds.size === filteredMeds.length ? deselectAll : selectAll}
|
||||||
|
>
|
||||||
|
{selectedIds.size === filteredMeds.length ? t("report.deselectAll") : t("report.selectAll")}
|
||||||
|
</button>
|
||||||
|
<span className="report-selection-count">
|
||||||
|
{selectedIds.size} / {filteredMeds.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeMeds.length > 0 && (
|
||||||
|
<div className="report-group">
|
||||||
|
<h4 className="report-group-title">{t("report.activeMeds")}</h4>
|
||||||
|
<div className="report-med-list">
|
||||||
|
{activeMeds.map((med) => (
|
||||||
|
<label key={med.id} className="report-med-item">
|
||||||
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
|
<span className="report-med-name">
|
||||||
|
{med.name}
|
||||||
|
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{obsoleteMeds.length > 0 && (
|
||||||
|
<div className="report-group">
|
||||||
|
<h4 className="report-group-title">{t("report.obsoleteMeds")}</h4>
|
||||||
|
<div className="report-med-list">
|
||||||
|
{obsoleteMeds.map((med) => (
|
||||||
|
<label key={med.id} className="report-med-item">
|
||||||
|
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
|
||||||
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
|
<span className="report-med-name obsolete-name">
|
||||||
|
{med.name}
|
||||||
|
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Format selection */}
|
||||||
|
<div className="report-format">
|
||||||
|
<h4>{t("report.format")}</h4>
|
||||||
|
<div className="report-format-options">
|
||||||
|
<label className={`report-format-option${format === "pdf" ? " selected" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="pdf"
|
||||||
|
checked={format === "pdf"}
|
||||||
|
onChange={() => setFormat("pdf")}
|
||||||
|
/>
|
||||||
|
<span>{t("report.formatPdf")}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`report-format-option${format === "txt" ? " selected" : ""}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="format"
|
||||||
|
value="txt"
|
||||||
|
checked={format === "txt"}
|
||||||
|
onChange={() => setFormat("txt")}
|
||||||
|
/>
|
||||||
|
<span>{t("report.formatTxt")}</span>
|
||||||
|
</label>
|
||||||
|
<label className={`report-format-option${format === "md" ? " selected" : ""}`}>
|
||||||
|
<input type="radio" name="format" value="md" checked={format === "md"} onChange={() => setFormat("md")} />
|
||||||
|
<span>{t("report.formatMd")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="report-actions">
|
||||||
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={selectedIds.size === 0 || generating}
|
||||||
|
>
|
||||||
|
{generating ? t("report.generating") : t("report.generate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Report generation helpers ───
|
||||||
|
|
||||||
|
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (!m) return "-";
|
||||||
|
return `${m[3]}.${m[2]}.${m[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "-";
|
||||||
|
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
|
if (!m) return `${fmtDate(iso)}`;
|
||||||
|
return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTextReport(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
fmt: "txt" | "md",
|
||||||
|
t: TFn,
|
||||||
|
personFilter: string[] | null
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const sep = fmt === "md" ? "---" : "═".repeat(60);
|
||||||
|
const h1 = (s: string) => (fmt === "md" ? `# ${s}` : s);
|
||||||
|
const h2 = (s: string) => (fmt === "md" ? `## ${s}` : s);
|
||||||
|
const h3 = (s: string) => (fmt === "md" ? `### ${s}` : ` ${s}`);
|
||||||
|
const bold = (s: string) => (fmt === "md" ? `**${s}**` : s);
|
||||||
|
const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`);
|
||||||
|
|
||||||
|
lines.push(h1(t("report.docTitle")));
|
||||||
|
lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (const med of meds) {
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push("");
|
||||||
|
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
|
||||||
|
lines.push(h2(title));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// General
|
||||||
|
lines.push(h3(t("report.docGeneral")));
|
||||||
|
lines.push(item(t("report.docCommercialName"), med.name));
|
||||||
|
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
|
||||||
|
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
|
||||||
|
lines.push(
|
||||||
|
item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))
|
||||||
|
);
|
||||||
|
if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate)));
|
||||||
|
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt)));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Package / Stock
|
||||||
|
lines.push(h3(t("report.docPackage")));
|
||||||
|
lines.push(
|
||||||
|
item(t("report.docPackageType"), med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))
|
||||||
|
);
|
||||||
|
if (med.packageType === "blister") {
|
||||||
|
lines.push(item(t("report.docPacks"), String(med.packCount)));
|
||||||
|
lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack)));
|
||||||
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
|
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||||
|
} else {
|
||||||
|
lines.push(item(t("report.docTotalCapacity"), String(med.totalPills ?? med.looseTablets)));
|
||||||
|
}
|
||||||
|
lines.push(item(t("report.docCurrentStock"), `${getPackageSize(med)} ${t("common.pills")}`));
|
||||||
|
if (med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`));
|
||||||
|
if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate)));
|
||||||
|
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Intake Schedule
|
||||||
|
const allIntakes = med.intakes ?? med.blisters;
|
||||||
|
const intakes = personFilter
|
||||||
|
? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||||
|
: allIntakes;
|
||||||
|
if (intakes?.length) {
|
||||||
|
lines.push(h3(t("report.docIntakeSchedule")));
|
||||||
|
for (const intake of intakes) {
|
||||||
|
let entry = `${intake.usage} ${intake.usage === 1 ? t("common.pill") : t("common.pills")}`;
|
||||||
|
entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`;
|
||||||
|
entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`;
|
||||||
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
|
entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
|
||||||
|
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||||
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prescription
|
||||||
|
if (med.prescriptionEnabled) {
|
||||||
|
lines.push(h3(t("report.docPrescription")));
|
||||||
|
lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0)));
|
||||||
|
lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0)));
|
||||||
|
if (med.prescriptionExpiryDate)
|
||||||
|
lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate)));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dose tracking data
|
||||||
|
const data = reportData[med.id];
|
||||||
|
if (data) {
|
||||||
|
lines.push(h3(t("report.docIntakeHistory")));
|
||||||
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
|
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
||||||
|
if (data.automaticDosesTaken > 0) {
|
||||||
|
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
||||||
|
}
|
||||||
|
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
||||||
|
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
||||||
|
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
||||||
|
} else {
|
||||||
|
lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Refill history
|
||||||
|
if (data.refills.length > 0) {
|
||||||
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
|
for (const r of data.refills) {
|
||||||
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${t("common.pills")}`;
|
||||||
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(sep);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(content: string, format: "txt" | "md") {
|
||||||
|
const mimeType = format === "md" ? "text/markdown" : "text/plain";
|
||||||
|
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10);
|
||||||
|
a.href = url;
|
||||||
|
a.download = `medassist-report-${dateStr}.${format}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageMap = Record<number, string>;
|
||||||
|
|
||||||
|
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
|
||||||
|
const map: ImageMap = {};
|
||||||
|
const fetches = meds
|
||||||
|
.filter((m) => m.imageUrl)
|
||||||
|
.map(async (m) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
map[m.id] = dataUrl;
|
||||||
|
} catch {
|
||||||
|
// Skip image on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(fetches);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPrintView(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
t: TFn,
|
||||||
|
imageMap: ImageMap,
|
||||||
|
personFilter: string[] | null
|
||||||
|
) {
|
||||||
|
const w = window.open("", "_blank");
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
|
||||||
|
w.document.write(html);
|
||||||
|
w.document.close();
|
||||||
|
w.onload = () => setTimeout(() => w.print(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrintHtml(
|
||||||
|
meds: Medication[],
|
||||||
|
reportData: ReportData,
|
||||||
|
t: TFn,
|
||||||
|
imageMap: ImageMap,
|
||||||
|
personFilter: string[] | null
|
||||||
|
): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
for (const med of meds) {
|
||||||
|
const data = reportData[med.id];
|
||||||
|
const intakes = med.intakes ?? med.blisters;
|
||||||
|
const title = med.isObsolete
|
||||||
|
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
|
||||||
|
: escHtml(med.name);
|
||||||
|
|
||||||
|
let s = `<div class="med-section">`;
|
||||||
|
const imgDataUrl = imageMap[med.id];
|
||||||
|
|
||||||
|
// Title with generic name subtitle
|
||||||
|
s += `<h2>${title}</h2>`;
|
||||||
|
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
|
||||||
|
|
||||||
|
// Build general info table rows
|
||||||
|
const generalRows: string[] = [];
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.genericName)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.takenBy?.length)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docTakenBy"))}</td><td>${escHtml(med.takenBy.join(", "))}</td></tr>`
|
||||||
|
);
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docStatus"))}</td><td>${escHtml(med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.medicationStartDate)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${fmtDate(med.medicationStartDate)}</td></tr>`
|
||||||
|
);
|
||||||
|
if (med.isObsolete && med.obsoleteAt)
|
||||||
|
generalRows.push(
|
||||||
|
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${fmtDate(med.obsoleteAt)}</td></tr>`
|
||||||
|
);
|
||||||
|
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
|
||||||
|
|
||||||
|
if (imgDataUrl) {
|
||||||
|
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(med.name)}" /><div class="med-overview-info">${generalTable}</div></div>`;
|
||||||
|
} else {
|
||||||
|
s += generalTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package / Stock
|
||||||
|
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
|
||||||
|
s += `<table><tbody>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(med.packageType === "bottle" ? t("report.docBottle") : t("report.docBlister"))}</td></tr>`;
|
||||||
|
if (med.packageType === "blister") {
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
|
||||||
|
if (med.looseTablets > 0)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||||
|
} else {
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docTotalCapacity"))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||||
|
}
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${getPackageSize(med)} ${escHtml(t("common.pills"))}</td></tr>`;
|
||||||
|
if (med.pillWeightMg)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
|
||||||
|
if (med.expiryDate)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${fmtDate(med.expiryDate)}</td></tr>`;
|
||||||
|
if (med.notes)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
|
||||||
|
// Intake Schedule
|
||||||
|
const allPrintIntakes = intakes;
|
||||||
|
const filteredPrintIntakes = personFilter
|
||||||
|
? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string))
|
||||||
|
: allPrintIntakes;
|
||||||
|
if (filteredPrintIntakes?.length) {
|
||||||
|
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
|
||||||
|
s += `<ul>`;
|
||||||
|
for (const intake of filteredPrintIntakes) {
|
||||||
|
let entry = `${intake.usage} ${escHtml(intake.usage === 1 ? t("common.pill") : t("common.pills"))}`;
|
||||||
|
entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`;
|
||||||
|
entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`;
|
||||||
|
if ("takenBy" in intake && intake.takenBy)
|
||||||
|
entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
|
||||||
|
if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`;
|
||||||
|
s += `<li>${entry}</li>`;
|
||||||
|
}
|
||||||
|
s += `</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prescription
|
||||||
|
if (med.prescriptionEnabled) {
|
||||||
|
s += `<h3>${escHtml(t("report.docPrescription"))}</h3>`;
|
||||||
|
s += `<table><tbody>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
|
||||||
|
if (med.prescriptionExpiryDate)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${fmtDate(med.prescriptionExpiryDate)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intake history
|
||||||
|
if (data) {
|
||||||
|
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
||||||
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
|
s += `<table><tbody>`;
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||||
|
if (data.automaticDosesTaken > 0) {
|
||||||
|
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||||
|
}
|
||||||
|
if (data.dosesDismissed > 0)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||||
|
if (data.firstDoseAt)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${fmtDate(data.firstDoseAt)}</td></tr>`;
|
||||||
|
if (data.lastDoseAt)
|
||||||
|
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${fmtDate(data.lastDoseAt)}</td></tr>`;
|
||||||
|
s += `</tbody></table>`;
|
||||||
|
} else {
|
||||||
|
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refill history
|
||||||
|
if (data.refills.length > 0) {
|
||||||
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
|
s += `<ul>`;
|
||||||
|
for (const r of data.refills) {
|
||||||
|
let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(t("common.pills"))}`;
|
||||||
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
|
s += `<li>${entry}</li>`;
|
||||||
|
}
|
||||||
|
s += `</ul>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s += `</div>`;
|
||||||
|
sections.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${escHtml(t("report.docTitle"))}</title>
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; padding: 1rem; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.med-section:last-child { margin-bottom: 0; padding-bottom: 0; }
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { color: #64748b; margin-bottom: 1rem; }
|
||||||
|
.med-section { margin-bottom: 1.5rem; padding-bottom: 1rem; }
|
||||||
|
.med-section:last-child { }
|
||||||
|
h2 { font-size: 1.25rem; color: #0f172a; margin: 0; }
|
||||||
|
.generic-subtitle { margin: 0.1rem 0 0.5rem; font-size: 0.9rem; font-style: italic; color: #64748b; }
|
||||||
|
h2 + .med-overview { margin-top: 0.5rem; }
|
||||||
|
.med-overview { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
|
.med-overview-info { flex: 1; min-width: 0; }
|
||||||
|
.med-overview-info h3 { margin-top: 0; }
|
||||||
|
.med-img { width: 220px; height: 220px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
|
||||||
|
h3 { font-size: 0.9rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 0.5rem; }
|
||||||
|
td { padding: 0.25rem 0.5rem; }
|
||||||
|
td.label { font-weight: 500; color: #475569; width: 40%; }
|
||||||
|
ul { margin: 0.25rem 0; padding-left: 1.5rem; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.obsolete-badge { font-size: 0.75rem; background: #fef3c7; color: #92400e; padding: 0.125rem 0.5rem; border-radius: 4px; vertical-align: middle; }
|
||||||
|
.no-data { color: #94a3b8; font-style: italic; }
|
||||||
|
.print-hint { text-align: center; padding: 1rem; background: #f0f9ff; border-radius: 8px; color: #0369a1; margin-bottom: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
||||||
|
<h1>${escHtml(t("report.docTitle"))}</h1>
|
||||||
|
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}</p>
|
||||||
|
${sections.join("\n")}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportModal;
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* ShareDialog - Modal for generating share links for medication schedules
|
* ShareDialog - Modal for generating share links for medication schedules
|
||||||
* Allows sharing schedule view for a specific person
|
* Allows sharing schedule view for a specific person
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Check, Copy, Link2, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export interface ShareDialogProps {
|
export interface ShareDialogProps {
|
||||||
@@ -38,26 +40,51 @@ export function ShareDialog({
|
|||||||
onCopyShareLink,
|
onCopyShareLink,
|
||||||
}: ShareDialogProps) {
|
}: ShareDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const closeLabel = t("common.close");
|
||||||
|
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
<button className="modal-close" onClick={onClose}>
|
onClick={onClose}
|
||||||
×
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content share-dialog-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close tooltip-trigger"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={closeLabel}
|
||||||
|
data-tooltip={closeLabel}
|
||||||
|
>
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="share-dialog-header">
|
<div className="share-dialog-header">
|
||||||
<h2>🔗 {t("share.title")}</h2>
|
<h2>
|
||||||
|
<Link2 size={18} aria-hidden="true" /> {t("share.title")}
|
||||||
|
</h2>
|
||||||
<p className="share-dialog-description">{t("share.description")}</p>
|
<p className="share-dialog-description">{t("share.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sharePeople.length === 0 ? (
|
{(() => {
|
||||||
|
if (sharePeople.length === 0) {
|
||||||
|
return (
|
||||||
<div className="share-dialog-empty">
|
<div className="share-dialog-empty">
|
||||||
<p>{t("share.noPeople")}</p>
|
<p>{t("share.noPeople")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : shareLink ? (
|
);
|
||||||
|
}
|
||||||
|
if (shareLink) {
|
||||||
|
return (
|
||||||
<div className="share-dialog-result">
|
<div className="share-dialog-result">
|
||||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||||
<div className="share-link-box">
|
<div className="share-link-box">
|
||||||
@@ -68,8 +95,14 @@ export function ShareDialog({
|
|||||||
className="share-link-input"
|
className="share-link-input"
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
/>
|
/>
|
||||||
<button className="btn-copy" onClick={onCopyShareLink}>
|
<button
|
||||||
{shareCopied ? "✓" : "📋"}
|
type="button"
|
||||||
|
className="btn-copy icon-only tooltip-trigger"
|
||||||
|
onClick={onCopyShareLink}
|
||||||
|
aria-label={copyLabel}
|
||||||
|
data-tooltip={copyLabel}
|
||||||
|
>
|
||||||
|
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||||
@@ -86,11 +119,17 @@ export function ShareDialog({
|
|||||||
<button onClick={onClose}>{t("common.close")}</button>
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="share-dialog-form">
|
<div className="share-dialog-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPerson")}</label>
|
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
<select
|
||||||
|
id="share-person-select"
|
||||||
|
value={shareSelectedPerson}
|
||||||
|
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||||
|
>
|
||||||
{sharePeople.map((person) => (
|
{sharePeople.map((person) => (
|
||||||
<option key={person} value={person}>
|
<option key={person} value={person}>
|
||||||
{person}
|
{person}
|
||||||
@@ -100,8 +139,12 @@ export function ShareDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t("share.selectPeriod")}</label>
|
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
<select
|
||||||
|
id="share-period-select"
|
||||||
|
value={shareSelectedDays}
|
||||||
|
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
@@ -110,14 +153,15 @@ export function ShareDialog({
|
|||||||
|
|
||||||
<div className="share-dialog-footer">
|
<div className="share-dialog-footer">
|
||||||
<button className="ghost" onClick={onClose}>
|
<button className="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SharedSchedule Component - Public view for shared schedules
|
// SharedSchedule Component - Public view for shared schedules
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
|
||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -209,7 +211,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
||||||
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
||||||
function getDoseId(doseId: string, _person: string | null): string {
|
function _getDoseId(doseId: string, _person: string | null): string {
|
||||||
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
||||||
return doseId;
|
return doseId;
|
||||||
}
|
}
|
||||||
@@ -479,7 +481,8 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null];
|
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||||
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||||
|
|
||||||
let timeBasedConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
let lastAutoConsumedDateMs = 0;
|
let lastAutoConsumedDateMs = 0;
|
||||||
@@ -579,11 +582,13 @@ export function SharedSchedule() {
|
|||||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
||||||
return status.className;
|
return status.className;
|
||||||
});
|
});
|
||||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether to show stock status indicators on the shared schedule
|
// Whether to show stock status indicators on the shared schedule
|
||||||
const showStock = data?.shareStockStatus !== false;
|
const showStock = data?.shareStockStatus !== false;
|
||||||
|
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||||
|
|
||||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||||
function isDoseIdDone(doseId: string): boolean {
|
function isDoseIdDone(doseId: string): boolean {
|
||||||
@@ -606,7 +611,7 @@ export function SharedSchedule() {
|
|||||||
const missedPastDoseIds = useMemo(() => {
|
const missedPastDoseIds = useMemo(() => {
|
||||||
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||||
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
|
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
|
||||||
}, [pastDays, takenDoses, dismissedDoses, data]);
|
}, [pastDays, isDoseIdDone]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -714,14 +719,20 @@ export function SharedSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="shared-schedule-period">
|
{!showOnlyToday &&
|
||||||
{t("share.period")}:{" "}
|
(() => {
|
||||||
{data.scheduleDays === 30
|
const periodLabel =
|
||||||
|
data.scheduleDays === 30
|
||||||
? t("dashboard.schedules.1month")
|
? t("dashboard.schedules.1month")
|
||||||
: data.scheduleDays === 90
|
: data.scheduleDays === 90
|
||||||
? t("dashboard.schedules.3months")
|
? t("dashboard.schedules.3months")
|
||||||
: t("dashboard.schedules.6months")}
|
: t("dashboard.schedules.6months");
|
||||||
|
return (
|
||||||
|
<p className="shared-schedule-period">
|
||||||
|
{t("share.period")}: {periodLabel}
|
||||||
</p>
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
@@ -730,7 +741,8 @@ export function SharedSchedule() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Past days (when expanded) — rendered above toggle */}
|
{/* Past days (when expanded) — rendered above toggle */}
|
||||||
{showPastDays &&
|
{!showOnlyToday &&
|
||||||
|
showPastDays &&
|
||||||
pastDays.map((day) => {
|
pastDays.map((day) => {
|
||||||
// Get ALL dose IDs for this day (for total count and yellow styling)
|
// Get ALL dose IDs for this day (for total count and yellow styling)
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
@@ -757,14 +769,18 @@ export function SharedSchedule() {
|
|||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
|
|
||||||
|
const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : "";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.dateStr}
|
key={day.dateStr}
|
||||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -817,11 +833,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -837,9 +860,12 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
@@ -859,7 +885,8 @@ export function SharedSchedule() {
|
|||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -875,7 +902,8 @@ export function SharedSchedule() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Past days toggle */}
|
{/* Past days toggle */}
|
||||||
{pastDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
pastDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const missedCount = missedPastDoseIds.length;
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||||
@@ -894,6 +922,9 @@ export function SharedSchedule() {
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||||
<span className="past-days-label">
|
<span className="past-days-label">
|
||||||
@@ -941,6 +972,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -982,11 +1016,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1006,9 +1047,12 @@ export function SharedSchedule() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div
|
<div
|
||||||
@@ -1030,7 +1074,8 @@ export function SharedSchedule() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,7 +1092,8 @@ export function SharedSchedule() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Future days toggle — identical to DashboardPage */}
|
{/* Future days toggle — identical to DashboardPage */}
|
||||||
{futureDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
futureDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
|
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
|
||||||
@@ -1058,6 +1104,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
||||||
onClick={() => setShowFutureDays(!showFutureDays)}
|
onClick={() => setShowFutureDays(!showFutureDays)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
||||||
<span className="future-days-label">
|
<span className="future-days-label">
|
||||||
@@ -1079,7 +1128,8 @@ export function SharedSchedule() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Future days (when expanded) — identical to DashboardPage */}
|
{/* Future days (when expanded) — identical to DashboardPage */}
|
||||||
{showFutureDays &&
|
{!showOnlyToday &&
|
||||||
|
showFutureDays &&
|
||||||
futureDays.map((day) => {
|
futureDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1099,6 +1149,9 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -1139,11 +1192,18 @@ export function SharedSchedule() {
|
|||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="med-name-stack">
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1159,9 +1219,12 @@ export function SharedSchedule() {
|
|||||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
@@ -1181,7 +1244,8 @@ export function SharedSchedule() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1215,7 +1279,13 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
{/* Image Lightbox */}
|
{/* Image Lightbox */}
|
||||||
{lightboxImage && (
|
{lightboxImage && (
|
||||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
<div
|
||||||
|
className="lightbox-overlay"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") closeLightbox();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button className="lightbox-close" onClick={closeLightbox}>
|
<button className="lightbox-close" onClick={closeLightbox}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -1224,6 +1294,7 @@ export function SharedSchedule() {
|
|||||||
alt={lightboxImage.name}
|
alt={lightboxImage.name}
|
||||||
className="lightbox-image"
|
className="lightbox-image"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,8 +36,18 @@ export function UserFilterModal({
|
|||||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div
|
||||||
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content user-meds-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button className="modal-close" onClick={onClose}>
|
<button className="modal-close" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -75,6 +85,12 @@ export function UserFilterModal({
|
|||||||
onClearUser();
|
onClearUser();
|
||||||
onOpenMedDetail(med);
|
onOpenMedDetail(med);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
onClearUser();
|
||||||
|
onOpenMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||||
<div className="user-med-info">
|
<div className="user-med-info">
|
||||||
@@ -82,18 +98,19 @@ export function UserFilterModal({
|
|||||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||||
{personIntakes.length > 0 && (
|
{personIntakes.length > 0 && (
|
||||||
<div className="user-med-intakes">
|
<div className="user-med-intakes">
|
||||||
{personIntakes.map((intake, idx) => {
|
{personIntakes.map((intake) => {
|
||||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="user-med-intake-item">
|
<span key={intakeKey} className="user-med-intake-item">
|
||||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med.pillWeightMg != null &&
|
{med.pillWeightMg != null &&
|
||||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||||
{t("form.blisters.every")} {intake.every}{" "}
|
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||||
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr}
|
{t("modal.at")} {timeStr}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type { MobileEditModalProps } from "./MobileEditModal";
|
|||||||
export { MobileEditModal } from "./MobileEditModal";
|
export { MobileEditModal } from "./MobileEditModal";
|
||||||
export { PasswordInput } from "./PasswordInput";
|
export { PasswordInput } from "./PasswordInput";
|
||||||
export { default as ProfileModal } from "./ProfileModal";
|
export { default as ProfileModal } from "./ProfileModal";
|
||||||
|
export { default as ReportModal } from "./ReportModal";
|
||||||
export type { ShareDialogProps } from "./ShareDialog";
|
export type { ShareDialogProps } from "./ShareDialog";
|
||||||
export { ShareDialog } from "./ShareDialog";
|
export { ShareDialog } from "./ShareDialog";
|
||||||
export { SharedSchedule } from "./SharedSchedule";
|
export { SharedSchedule } from "./SharedSchedule";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
||||||
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
|
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -72,6 +72,7 @@ export interface AppContextValue {
|
|||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -119,12 +120,15 @@ export interface AppContextValue {
|
|||||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockPartialBlisterPills: number;
|
editStockPartialBlisterPills: number;
|
||||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
editStockLoosePills: number;
|
||||||
|
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockSaving: boolean;
|
editStockSaving: boolean;
|
||||||
|
editStockMedication: Medication | null;
|
||||||
loadRefillHistory: (medId: number) => Promise<void>;
|
loadRefillHistory: (medId: number) => Promise<void>;
|
||||||
submitRefill: (
|
submitRefill: (
|
||||||
medId: number,
|
medId: number,
|
||||||
editingId: number | null,
|
editingId: number | null,
|
||||||
setForm: React.Dispatch<React.SetStateAction<any>>,
|
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||||
loadMeds: () => void,
|
loadMeds: () => void,
|
||||||
usePrescription?: boolean
|
usePrescription?: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
@@ -175,8 +179,20 @@ export interface AppContextValue {
|
|||||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
pendingImportData: unknown;
|
pendingImportData: unknown;
|
||||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||||
importResult: { medications: number; doses: number; shares: number } | null;
|
importResult: {
|
||||||
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null;
|
||||||
|
setImportResult: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null>
|
||||||
|
>;
|
||||||
handleExport: (includeImages?: boolean) => Promise<void>;
|
handleExport: (includeImages?: boolean) => Promise<void>;
|
||||||
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
handleImportConfirm: () => Promise<void>;
|
handleImportConfirm: () => Promise<void>;
|
||||||
@@ -197,7 +213,17 @@ export interface AppContextValue {
|
|||||||
// Context
|
// Context
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const AppContext = createContext<AppContextValue | null>(null);
|
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
|
||||||
|
|
||||||
|
const AppContext = (() => {
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
|
||||||
|
};
|
||||||
|
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
|
||||||
|
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
|
||||||
|
}
|
||||||
|
return globalRef[APP_CONTEXT_SINGLETON_KEY];
|
||||||
|
})();
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
@@ -227,9 +253,32 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
const [selectedMed, setSelectedMed] = useState<Medication | null>(null);
|
||||||
|
const selectedMedIdRef = useRef<number | null>(null);
|
||||||
|
const medDetailOpenedAtRef = useRef(0);
|
||||||
|
const medDetailCloseInFlightRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
selectedMedIdRef.current = selectedMed?.id ?? null;
|
||||||
|
if (!selectedMed) {
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [selectedMed]);
|
||||||
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
const [showImageLightbox, setShowImageLightbox] = useState(false);
|
||||||
|
const imageLightboxOpenedAtRef = useRef(0);
|
||||||
|
const imageLightboxCloseInFlightRef = useRef(false);
|
||||||
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
const [scheduleLightboxImage, setScheduleLightboxImage] = useState<string | null>(null);
|
||||||
|
const scheduleLightboxOpenedAtRef = useRef(0);
|
||||||
|
const scheduleLightboxCloseInFlightRef = useRef(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showImageLightbox) {
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scheduleLightboxImage) {
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -237,7 +286,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||||
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
|
const [importResult, setImportResult] = useState<{
|
||||||
|
medications: number;
|
||||||
|
doses: number;
|
||||||
|
refills: number;
|
||||||
|
shares: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Load user-specific scheduleDays when user changes
|
// Load user-specific scheduleDays when user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,7 +403,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Normal/High stock
|
// Normal/High stock
|
||||||
return "success";
|
return "success";
|
||||||
});
|
});
|
||||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||||
|
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||||
},
|
},
|
||||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
||||||
);
|
);
|
||||||
@@ -435,6 +490,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Modal helpers with browser history support
|
// Modal helpers with browser history support
|
||||||
const openMedDetail = useCallback(
|
const openMedDetail = useCallback(
|
||||||
(med: Medication) => {
|
(med: Medication) => {
|
||||||
|
if (selectedMedIdRef.current === med.id) return;
|
||||||
|
selectedMedIdRef.current = med.id;
|
||||||
|
medDetailOpenedAtRef.current = Date.now();
|
||||||
|
medDetailCloseInFlightRef.current = false;
|
||||||
setSelectedMed(med);
|
setSelectedMed(med);
|
||||||
refill.setRefillHistoryExpanded(false);
|
refill.setRefillHistoryExpanded(false);
|
||||||
refill.loadRefillHistory(med.id);
|
refill.loadRefillHistory(med.id);
|
||||||
@@ -444,37 +503,78 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const closeMedDetail = useCallback(() => {
|
const closeMedDetail = useCallback(() => {
|
||||||
if (selectedMed) {
|
if (!selectedMed || medDetailCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
|
||||||
|
// Ignore ultra-fast close requests caused by rapid double-click races
|
||||||
|
if (Date.now() - medDetailOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "medDetail") {
|
||||||
|
// State already popped by another event: close locally without another back step.
|
||||||
|
selectedMedIdRef.current = null;
|
||||||
|
setSelectedMed(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
medDetailCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
}, [selectedMed]);
|
}, [selectedMed]);
|
||||||
|
|
||||||
const openImageLightbox = useCallback(() => {
|
const openImageLightbox = useCallback(() => {
|
||||||
|
if (showImageLightbox) return;
|
||||||
|
imageLightboxOpenedAtRef.current = Date.now();
|
||||||
|
imageLightboxCloseInFlightRef.current = false;
|
||||||
setShowImageLightbox(true);
|
setShowImageLightbox(true);
|
||||||
window.history.pushState({ modal: "imageLightbox" }, "");
|
window.history.pushState({ modal: "imageLightbox" }, "");
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeImageLightbox = useCallback(() => {
|
|
||||||
if (showImageLightbox) {
|
|
||||||
window.history.back();
|
|
||||||
}
|
|
||||||
}, [showImageLightbox]);
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
const openScheduleLightbox = useCallback((imageUrl: string) => {
|
const closeImageLightbox = useCallback(() => {
|
||||||
|
if (!showImageLightbox || imageLightboxCloseInFlightRef.current) return;
|
||||||
|
if (Date.now() - imageLightboxOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "imageLightbox") {
|
||||||
|
setShowImageLightbox(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageLightboxCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
|
}, [showImageLightbox]);
|
||||||
|
|
||||||
|
const openScheduleLightbox = useCallback(
|
||||||
|
(imageUrl: string) => {
|
||||||
|
if (scheduleLightboxImage) return;
|
||||||
|
scheduleLightboxOpenedAtRef.current = Date.now();
|
||||||
|
scheduleLightboxCloseInFlightRef.current = false;
|
||||||
setScheduleLightboxImage(imageUrl);
|
setScheduleLightboxImage(imageUrl);
|
||||||
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
window.history.pushState({ modal: "scheduleLightbox" }, "");
|
||||||
}, []);
|
},
|
||||||
|
[scheduleLightboxImage]
|
||||||
|
);
|
||||||
|
|
||||||
const closeScheduleLightbox = useCallback(() => {
|
const closeScheduleLightbox = useCallback(() => {
|
||||||
if (scheduleLightboxImage) {
|
if (!scheduleLightboxImage || scheduleLightboxCloseInFlightRef.current) return;
|
||||||
window.history.back();
|
if (Date.now() - scheduleLightboxOpenedAtRef.current < 320) return;
|
||||||
|
|
||||||
|
const currentState = window.history.state as { modal?: string } | null;
|
||||||
|
if (currentState?.modal !== "scheduleLightbox") {
|
||||||
|
setScheduleLightboxImage(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLightboxCloseInFlightRef.current = true;
|
||||||
|
window.history.back();
|
||||||
}, [scheduleLightboxImage]);
|
}, [scheduleLightboxImage]);
|
||||||
|
|
||||||
const openUserFilter = useCallback((person: string) => {
|
const openUserFilter = useCallback(
|
||||||
|
(person: string) => {
|
||||||
|
if (selectedUser === person) return;
|
||||||
setSelectedUser(person);
|
setSelectedUser(person);
|
||||||
window.history.pushState({ modal: "userFilter", person }, "");
|
window.history.pushState({ modal: "userFilter", person }, "");
|
||||||
}, []);
|
},
|
||||||
|
[selectedUser]
|
||||||
|
);
|
||||||
|
|
||||||
const closeUserFilter = useCallback(() => {
|
const closeUserFilter = useCallback(() => {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
@@ -505,9 +605,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
const dateStr = new Date().toISOString().split("T")[0];
|
const now = new Date();
|
||||||
|
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||||
|
const userPart = user?.username ? `-${user.username}` : "";
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${t("exportImport.downloadFilename")}-${dateStr}.json`;
|
a.download = `${t("exportImport.downloadFilename")}${userPart}-${dateStr}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@@ -517,7 +619,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
},
|
},
|
||||||
[t]
|
[t, user?.username]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle file selection for import
|
// Handle file selection for import
|
||||||
@@ -581,6 +683,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setImportResult({
|
setImportResult({
|
||||||
medications: data.imported?.medications || 0,
|
medications: data.imported?.medications || 0,
|
||||||
doses: data.imported?.doseHistory || 0,
|
doses: data.imported?.doseHistory || 0,
|
||||||
|
refills: data.imported?.refillHistory || 0,
|
||||||
shares: data.imported?.shareLinks || 0,
|
shares: data.imported?.shareLinks || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -623,6 +726,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
||||||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
|
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
|
||||||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
|
settings.shareStockStatus !== savedSettings.shareStockStatus ||
|
||||||
|
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
|
||||||
|
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
|
||||||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
|
settings.expiryWarningDays !== savedSettings.expiryWarningDays
|
||||||
);
|
);
|
||||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||||
@@ -706,6 +811,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||||
getDoseId: doses.getDoseId,
|
getDoseId: doses.getDoseId,
|
||||||
|
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||||
countTakenDoses: doses.countTakenDoses,
|
countTakenDoses: doses.countTakenDoses,
|
||||||
markDoseTaken: doses.markDoseTaken,
|
markDoseTaken: doses.markDoseTaken,
|
||||||
undoDoseTaken: doses.undoDoseTaken,
|
undoDoseTaken: doses.undoDoseTaken,
|
||||||
@@ -753,7 +859,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
||||||
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
||||||
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
||||||
|
editStockLoosePills: refill.editStockLoosePills,
|
||||||
|
setEditStockLoosePills: refill.setEditStockLoosePills,
|
||||||
editStockSaving: refill.editStockSaving,
|
editStockSaving: refill.editStockSaving,
|
||||||
|
editStockMedication: refill.editStockMedication,
|
||||||
loadRefillHistory: refill.loadRefillHistory,
|
loadRefillHistory: refill.loadRefillHistory,
|
||||||
submitRefill: refill.submitRefill,
|
submitRefill: refill.submitRefill,
|
||||||
submitStockCorrection: refill.submitStockCorrection,
|
submitStockCorrection: refill.submitStockCorrection,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
|
|||||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||||
export type { UseMedicationsReturn } from "./useMedications";
|
export type { UseMedicationsReturn } from "./useMedications";
|
||||||
export { useMedications } from "./useMedications";
|
export { useMedications } from "./useMedications";
|
||||||
|
export { useModalHistory } from "./useModalHistory";
|
||||||
export type { UseRefillReturn } from "./useRefill";
|
export type { UseRefillReturn } from "./useRefill";
|
||||||
export { useRefill } from "./useRefill";
|
export { useRefill } from "./useRefill";
|
||||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
|
|||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
takenDoseTimestamps: Map<string, number>;
|
takenDoseTimestamps: Map<string, number>;
|
||||||
|
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||||
dismissedDoses: Set<string>;
|
dismissedDoses: Set<string>;
|
||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
|
|||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||||
|
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
||||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const timestamps = new Map<string, number>();
|
const timestamps = new Map<string, number>();
|
||||||
|
const sources = new Map<string, "manual" | "automatic">();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses) {
|
for (const d of data.doses) {
|
||||||
if (d.dismissed) {
|
if (d.dismissed) {
|
||||||
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
timestamps.set(d.doseId, d.takenAt);
|
timestamps.set(d.doseId, d.takenAt);
|
||||||
|
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setTakenDoseTimestamps(timestamps);
|
setTakenDoseTimestamps(timestamps);
|
||||||
|
setTakenDoseSources(sources);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
|
|||||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isDoseTakenAutomatically = useCallback(
|
||||||
|
(doseId: string): boolean => {
|
||||||
|
return takenDoseSources.get(doseId) === "automatic";
|
||||||
|
},
|
||||||
|
[takenDoseSources]
|
||||||
|
);
|
||||||
|
|
||||||
// Count taken doses for a day/item
|
// Count taken doses for a day/item
|
||||||
const countTakenDoses = useCallback(
|
const countTakenDoses = useCallback(
|
||||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||||
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.set(doseId, Date.now());
|
next.set(doseId, Date.now());
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(doseId, "manual");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
mutationInFlightRef.current--;
|
mutationInFlightRef.current--;
|
||||||
// Re-sync with server after mutation completes
|
// Re-sync with server after mutation completes
|
||||||
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
takenDoses,
|
takenDoses,
|
||||||
setTakenDoses,
|
setTakenDoses,
|
||||||
takenDoseTimestamps,
|
takenDoseTimestamps,
|
||||||
|
takenDoseSources,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
countTakenDoses,
|
countTakenDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
if (error) errors[f] = error;
|
if (error) errors[f] = error;
|
||||||
});
|
});
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
}, [form.name, form.genericName, form.notes, validateField]);
|
}, [form.name, form.genericName, form.notes, validateField, form]);
|
||||||
|
|
||||||
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
@@ -190,6 +190,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
setEditingId(med.id);
|
setEditingId(med.id);
|
||||||
setTakenByInput(""); // Clear tag input when starting edit
|
setTakenByInput(""); // Clear tag input when starting edit
|
||||||
setFormSaved(true); // Existing medication is already saved
|
setFormSaved(true); // Existing medication is already saved
|
||||||
|
setFieldErrors({}); // Prevent one-frame stale error highlight from previous/default form state
|
||||||
|
|
||||||
// Parse intakes - prefer new format, fallback to legacy blisters
|
// Parse intakes - prefer new format, fallback to legacy blisters
|
||||||
const intakesFromApi =
|
const intakesFromApi =
|
||||||
@@ -215,6 +216,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||||
|
|
||||||
|
const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : "";
|
||||||
const editForm: FormState = {
|
const editForm: FormState = {
|
||||||
name: med.name,
|
name: med.name,
|
||||||
genericName: med.genericName ?? "",
|
genericName: med.genericName ?? "",
|
||||||
@@ -223,11 +225,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
packCount: String(med.packCount),
|
packCount: String(med.packCount),
|
||||||
blistersPerPack: String(med.blistersPerPack),
|
blistersPerPack: String(med.blistersPerPack),
|
||||||
pillsPerBlister: String(med.pillsPerBlister),
|
pillsPerBlister: String(med.pillsPerBlister),
|
||||||
totalPills: med.totalPills
|
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
||||||
? String(med.totalPills)
|
|
||||||
: med.packageType === "bottle" && med.looseTablets
|
|
||||||
? String(med.looseTablets)
|
|
||||||
: "",
|
|
||||||
looseTablets: String(med.looseTablets),
|
looseTablets: String(med.looseTablets),
|
||||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||||
doseUnit: med.doseUnit ?? "mg",
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
@@ -262,6 +260,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
setPendingImage(null);
|
setPendingImage(null);
|
||||||
setPendingImagePreview(null);
|
setPendingImagePreview(null);
|
||||||
setTakenByInput("");
|
setTakenByInput("");
|
||||||
|
setFieldErrors({});
|
||||||
setFormSaved(false);
|
setFormSaved(false);
|
||||||
const newForm = defaultForm();
|
const newForm = defaultForm();
|
||||||
setForm(newForm);
|
setForm(newForm);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a history entry when a modal opens so the browser back button closes it.
|
||||||
|
* On popstate (back), calls `onClose` to dismiss the modal.
|
||||||
|
*/
|
||||||
|
export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () => void) {
|
||||||
|
const pushedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
window.history.pushState({ modal: modalKey }, "");
|
||||||
|
pushedRef.current = true;
|
||||||
|
} else if (pushedRef.current) {
|
||||||
|
pushedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isOpen, modalKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (pushedRef.current) {
|
||||||
|
pushedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||||
import { getMedTotal, getPackageSize } from "../types";
|
import { getMedTotal, getPackageSize } from "../types";
|
||||||
|
|
||||||
@@ -24,7 +24,10 @@ export interface UseRefillReturn {
|
|||||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockPartialBlisterPills: number;
|
editStockPartialBlisterPills: number;
|
||||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
editStockLoosePills: number;
|
||||||
|
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
|
||||||
editStockSaving: boolean;
|
editStockSaving: boolean;
|
||||||
|
editStockMedication: Medication | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
loadRefillHistory: (medId: number) => Promise<void>;
|
loadRefillHistory: (medId: number) => Promise<void>;
|
||||||
@@ -56,7 +59,9 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||||
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||||
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
||||||
|
const [editStockLoosePills, setEditStockLoosePills] = useState(0);
|
||||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||||
|
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
||||||
|
|
||||||
// Load refill history for a medication
|
// Load refill history for a medication
|
||||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||||
@@ -132,42 +137,60 @@ export function useRefill(): UseRefillReturn {
|
|||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
setEditStockSaving(true);
|
setEditStockSaving(true);
|
||||||
try {
|
try {
|
||||||
// Auto-convert: handle full blister and negative partial blister
|
// Clamp all fields to non-negative values.
|
||||||
let finalFullBlisters = editStockFullBlisters;
|
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
||||||
let finalPartialPills = editStockPartialBlisterPills;
|
let finalPartialPills =
|
||||||
|
selectedMed.packageType === "bottle"
|
||||||
|
? Math.max(0, editStockPartialBlisterPills)
|
||||||
|
: Math.max(0, editStockPartialBlisterPills);
|
||||||
|
const finalLoosePills = Math.max(0, editStockLoosePills);
|
||||||
|
|
||||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
||||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
|
||||||
finalFullBlisters += 1;
|
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
||||||
finalPartialPills = 0;
|
finalPartialPills %= selectedMed.pillsPerBlister;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
// Structural max = sealed package capacity only (no looseTablets offset).
|
||||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
const structuralMax =
|
||||||
finalFullBlisters -= 1;
|
selectedMed.packageType === "bottle"
|
||||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||||
}
|
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||||
|
|
||||||
// Ensure we don't go negative
|
// For blister meds, only sealed pills are capped to package size.
|
||||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
// Loose pills are extra and can be above package size.
|
||||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
const desiredTotal =
|
||||||
|
selectedMed.packageType === "bottle"
|
||||||
// What the user says they have RIGHT NOW = the new DB total
|
? Math.min(structuralMax, Math.max(0, finalPartialPills))
|
||||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||||
|
finalLoosePills;
|
||||||
// The "base" from DB structure (without any stockAdjustment)
|
|
||||||
// Use getPackageSize() which handles both blister and bottle types correctly
|
|
||||||
const baseTotal = getPackageSize(selectedMed);
|
|
||||||
|
|
||||||
|
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
||||||
|
// - Bottle: looseTablets is the base (not changed during correction)
|
||||||
|
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
||||||
|
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
||||||
|
const baseTotal =
|
||||||
|
selectedMed.packageType === "bottle"
|
||||||
|
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
|
||||||
|
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||||
const newStockAdjustment = desiredTotal - baseTotal;
|
const newStockAdjustment = desiredTotal - baseTotal;
|
||||||
|
|
||||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
// For blister corrections also send the new looseTablets value so the DB
|
||||||
|
// reflects the actual loose count (avoids stale-split display on reload).
|
||||||
|
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
|
||||||
|
stockAdjustment: newStockAdjustment,
|
||||||
|
};
|
||||||
|
if (selectedMed.packageType !== "bottle") {
|
||||||
|
patchBody.looseTablets = finalLoosePills;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
|
||||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Close edit stock modal via history back
|
// Close edit stock modal via history back
|
||||||
@@ -182,7 +205,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
}
|
}
|
||||||
setEditStockSaving(false);
|
setEditStockSaving(false);
|
||||||
},
|
},
|
||||||
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
|
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openRefillModal = useCallback(() => {
|
const openRefillModal = useCallback(() => {
|
||||||
@@ -198,25 +221,51 @@ export function useRefill(): UseRefillReturn {
|
|||||||
|
|
||||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||||
if (!selectedMed) return;
|
if (!selectedMed) return;
|
||||||
|
setEditStockMedication(selectedMed);
|
||||||
// Get current stock from coverage (after consumption)
|
// Get current stock from coverage (after consumption)
|
||||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||||
const dbTotal = getMedTotal(selectedMed);
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
const currentStock = Math.max(0, medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal);
|
||||||
|
|
||||||
// Simply divide into full blisters and partial
|
// Bottle correction uses only total pills input.
|
||||||
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
// For blister, keep loose pills separated from sealed blister/partial counts.
|
||||||
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
||||||
|
const sealedPills = Math.max(0, currentStock - knownLoose);
|
||||||
|
const fullBlisters =
|
||||||
|
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
||||||
|
const partialPills =
|
||||||
|
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
||||||
|
|
||||||
// Pre-fill with current values
|
// Pre-fill with current values
|
||||||
setEditStockFullBlisters(fullBlisters);
|
setEditStockFullBlisters(fullBlisters);
|
||||||
setEditStockPartialBlisterPills(partialPills);
|
setEditStockPartialBlisterPills(partialPills);
|
||||||
|
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
|
||||||
setShowEditStockModal(true);
|
setShowEditStockModal(true);
|
||||||
window.history.pushState({ modal: "editStock" }, "");
|
window.history.pushState({ modal: "editStock" }, "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeEditStockModal = useCallback(() => {
|
const closeEditStockModal = useCallback(() => {
|
||||||
if (showEditStockModal) {
|
if (showEditStockModal) {
|
||||||
|
let popstateHandled = false;
|
||||||
|
const handlePopstate = () => {
|
||||||
|
popstateHandled = true;
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", handlePopstate, { once: true });
|
||||||
window.history.back();
|
window.history.back();
|
||||||
|
|
||||||
|
// Fallback for cases where no history entry exists for edit stock.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!popstateHandled) {
|
||||||
|
window.removeEventListener("popstate", handlePopstate);
|
||||||
|
setShowEditStockModal(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
}, [showEditStockModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEditStockModal) {
|
||||||
|
setEditStockMedication(null);
|
||||||
}
|
}
|
||||||
}, [showEditStockModal]);
|
}, [showEditStockModal]);
|
||||||
|
|
||||||
@@ -239,7 +288,10 @@ export function useRefill(): UseRefillReturn {
|
|||||||
setEditStockFullBlisters,
|
setEditStockFullBlisters,
|
||||||
editStockPartialBlisterPills,
|
editStockPartialBlisterPills,
|
||||||
setEditStockPartialBlisterPills,
|
setEditStockPartialBlisterPills,
|
||||||
|
editStockLoosePills,
|
||||||
|
setEditStockLoosePills,
|
||||||
editStockSaving,
|
editStockSaving,
|
||||||
|
editStockMedication,
|
||||||
loadRefillHistory,
|
loadRefillHistory,
|
||||||
submitRefill,
|
submitRefill,
|
||||||
submitStockCorrection,
|
submitStockCorrection,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export interface Settings {
|
|||||||
shoutrrrPrescriptionReminders: boolean;
|
shoutrrrPrescriptionReminders: boolean;
|
||||||
stockCalculationMode: "automatic" | "manual";
|
stockCalculationMode: "automatic" | "manual";
|
||||||
shareStockStatus: boolean;
|
shareStockStatus: boolean;
|
||||||
|
upcomingTodayOnly: boolean;
|
||||||
|
shareScheduleTodayOnly: boolean;
|
||||||
|
swapDashboardMainSections: boolean;
|
||||||
expiryWarningDays: number;
|
expiryWarningDays: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,9 @@ const defaultSettings: Settings = {
|
|||||||
shoutrrrPrescriptionReminders: true,
|
shoutrrrPrescriptionReminders: true,
|
||||||
stockCalculationMode: "automatic",
|
stockCalculationMode: "automatic",
|
||||||
shareStockStatus: true,
|
shareStockStatus: true,
|
||||||
|
upcomingTodayOnly: false,
|
||||||
|
shareScheduleTodayOnly: false,
|
||||||
|
swapDashboardMainSections: false,
|
||||||
expiryWarningDays: 30,
|
expiryWarningDays: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +230,9 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
|
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
|
||||||
stockCalculationMode: settingsToSave.stockCalculationMode,
|
stockCalculationMode: settingsToSave.stockCalculationMode,
|
||||||
shareStockStatus: settingsToSave.shareStockStatus,
|
shareStockStatus: settingsToSave.shareStockStatus,
|
||||||
|
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
|
||||||
|
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
|
||||||
|
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
smtpHost: settingsToSave.smtpHost,
|
smtpHost: settingsToSave.smtpHost,
|
||||||
smtpPort: settingsToSave.smtpPort,
|
smtpPort: settingsToSave.smtpPort,
|
||||||
@@ -240,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
const updatedSettings = {
|
const updatedSettings = { ...settingsToSave };
|
||||||
...settingsToSave,
|
|
||||||
emailEnabled: effectiveEmailEnabled,
|
|
||||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
|
||||||
};
|
|
||||||
setSettings(updatedSettings);
|
setSettings(updatedSettings);
|
||||||
setSettingsSaving(false);
|
setSettingsSaving(false);
|
||||||
setSavedSettings(updatedSettings);
|
setSavedSettings(updatedSettings);
|
||||||
|
|||||||
+110
-15
@@ -76,7 +76,7 @@
|
|||||||
"emptyStock_other": "{{count}} Medikamente leer",
|
"emptyStock_other": "{{count}} Medikamente leer",
|
||||||
"lowWarning": "{{count}} Medikament kritisch niedrig",
|
"lowWarning": "{{count}} Medikament kritisch niedrig",
|
||||||
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
|
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
|
||||||
"waitingFirstCheck": "Warte auf erste Prüfung",
|
"waitingFirstCheck": "Warte auf die erste Prüfung",
|
||||||
"type": "Typ",
|
"type": "Typ",
|
||||||
"typeStock": "Bestand",
|
"typeStock": "Bestand",
|
||||||
"typeIntake": "Einnahme",
|
"typeIntake": "Einnahme",
|
||||||
@@ -152,14 +152,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Medikament bearbeiten",
|
"editEntry": "Bearbeiten",
|
||||||
"viewEntry": "Medikament ansehen",
|
"editEntryWithName": "Bearbeiten: {{name}}",
|
||||||
|
"viewEntry": "Ansehen",
|
||||||
"newEntry": "Neues Medikament",
|
"newEntry": "Neues Medikament",
|
||||||
"badge": "Packungen + lose Tabletten",
|
"badge": "Packungen + lose Tabletten",
|
||||||
"sections": {
|
"sections": {
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"stock": "Bestand & Dosis",
|
"stock": "Package",
|
||||||
"prescription": "Rezept"
|
"prescription": "Rezept",
|
||||||
|
"prescriptionAndRefill": "Rezept & Nachfüllen",
|
||||||
|
"schedule": "Einnahme"
|
||||||
},
|
},
|
||||||
"commercialName": "Handelsname",
|
"commercialName": "Handelsname",
|
||||||
"genericName": "Wirkstoff",
|
"genericName": "Wirkstoff",
|
||||||
@@ -175,7 +178,7 @@
|
|||||||
"loosePills": "Lose Tabletten",
|
"loosePills": "Lose Tabletten",
|
||||||
"pillWeight": "Dosis pro Tablette",
|
"pillWeight": "Dosis pro Tablette",
|
||||||
"total": "Gesamt (Tabletten)",
|
"total": "Gesamt (Tabletten)",
|
||||||
"medicationStartDate": "Medikations-Startdatum",
|
"medicationStartDate": "Startdatum der Medikation",
|
||||||
"expiryDate": "Ablaufdatum",
|
"expiryDate": "Ablaufdatum",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"medicationImage": "Medikamentenbild",
|
"medicationImage": "Medikamentenbild",
|
||||||
@@ -240,7 +243,7 @@
|
|||||||
"stockReminders": "Bestands-Erinnerungen",
|
"stockReminders": "Bestands-Erinnerungen",
|
||||||
"intakeReminders": "Einnahme-Erinnerungen",
|
"intakeReminders": "Einnahme-Erinnerungen",
|
||||||
"prescriptionReminders": "Rezept-Erinnerungen",
|
"prescriptionReminders": "Rezept-Erinnerungen",
|
||||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
"enableHint": "Aktiviere mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||||
@@ -279,7 +282,7 @@
|
|||||||
"automatic": "Automatisch",
|
"automatic": "Automatisch",
|
||||||
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
|
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
|
||||||
"manual": "Manuell",
|
"manual": "Manuell",
|
||||||
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden",
|
"manualDesc": "Bestand wird nur reduziert, wenn Dosen als genommen markiert werden",
|
||||||
"thresholds": "Schwellenwerte",
|
"thresholds": "Schwellenwerte",
|
||||||
"criticalStockDays": "Kritisch (Tage)",
|
"criticalStockDays": "Kritisch (Tage)",
|
||||||
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
|
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
|
||||||
@@ -287,10 +290,22 @@
|
|||||||
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
|
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
|
||||||
"highStockDays": "Hoch (Tage)",
|
"highStockDays": "Hoch (Tage)",
|
||||||
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
||||||
"thresholdValidation": "Werte müssen sein: Kritisch < Niedrig < Hoch",
|
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
||||||
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
||||||
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
|
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "Allgemeine UI",
|
||||||
|
"upcomingSection": "Bevorstehender Zeitplan",
|
||||||
|
"upcomingTodayOnly": "Nur heute anzeigen",
|
||||||
|
"upcomingTodayOnlyDesc": "Vergangene und zukünftige Tage ausblenden und im Dashboard nur den heutigen Zeitplan anzeigen.",
|
||||||
|
"dashboardSectionOrder": "Dashboard-Layout",
|
||||||
|
"swapDashboardSections": "Bevorstehenden Zeitplan vor Medikamentenübersicht anzeigen",
|
||||||
|
"swapDashboardSectionsDesc": "Wenn aktiviert, wird der Bereich mit bevorstehenden Einnahmen über der Medikamentenübersicht angezeigt.",
|
||||||
|
"sharedSection": "Geteilter Zeitplan",
|
||||||
|
"shareScheduleTodayOnly": "Geteilte Links zeigen nur heute",
|
||||||
|
"shareScheduleTodayOnlyDesc": "Vergangene und zukünftige Tage in geteilten Zeitplänen ausblenden und nur heutige Einträge zeigen."
|
||||||
|
},
|
||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Bestands-Erinnerung",
|
"title": "Bestands-Erinnerung",
|
||||||
"description": "Bestands-Erinnerungen aktivieren",
|
"description": "Bestands-Erinnerungen aktivieren",
|
||||||
@@ -336,6 +351,7 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||||
|
"automaticTaken": "Automatisch eingenommen",
|
||||||
"hasNotes": "Hat Notizen",
|
"hasNotes": "Hat Notizen",
|
||||||
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
||||||
"lightMode": "Zum hellen Modus wechseln",
|
"lightMode": "Zum hellen Modus wechseln",
|
||||||
@@ -349,7 +365,8 @@
|
|||||||
},
|
},
|
||||||
"dose": {
|
"dose": {
|
||||||
"takenBy": "eingenommen von",
|
"takenBy": "eingenommen von",
|
||||||
"markAsTaken": "Als eingenommen markieren"
|
"markAsTaken": "Als eingenommen markieren",
|
||||||
|
"take": "Nehmen"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -377,7 +394,7 @@
|
|||||||
"checkEmail": "E-Mail überprüfen",
|
"checkEmail": "E-Mail überprüfen",
|
||||||
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
|
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
|
||||||
"passwordReset": "Passwort zurückgesetzt",
|
"passwordReset": "Passwort zurückgesetzt",
|
||||||
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
|
"passwordResetSuccess": "Dein Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
|
||||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||||
"rememberMe": "Angemeldet bleiben",
|
"rememberMe": "Angemeldet bleiben",
|
||||||
"localAccount": "Lokales Konto",
|
"localAccount": "Lokales Konto",
|
||||||
@@ -414,7 +431,7 @@
|
|||||||
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
||||||
"tooLong": "{{current}}/{{max}} Zeichen"
|
"tooLong": "{{current}}/{{max}} Zeichen"
|
||||||
},
|
},
|
||||||
"saved": "Gespeichert ✓",
|
"saved": "Gespeichert",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -434,6 +451,8 @@
|
|||||||
"of": "von",
|
"of": "von",
|
||||||
"loose": "lose",
|
"loose": "lose",
|
||||||
"none": "Kein",
|
"none": "Kein",
|
||||||
|
"daily": "täglich",
|
||||||
|
"everyNDays": "alle {{count}} Tage",
|
||||||
"day": "Tag",
|
"day": "Tag",
|
||||||
"days": "Tage",
|
"days": "Tage",
|
||||||
"blister": "Blister",
|
"blister": "Blister",
|
||||||
@@ -445,7 +464,9 @@
|
|||||||
"pillsTotal": "{{count}} Tabletten gesamt",
|
"pillsTotal": "{{count}} Tabletten gesamt",
|
||||||
"pillsTotal_one": "{{count}} Tablette gesamt",
|
"pillsTotal_one": "{{count}} Tablette gesamt",
|
||||||
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
||||||
"max": "max"
|
"max": "max",
|
||||||
|
"on": "An",
|
||||||
|
"off": "Aus"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Teilen",
|
"button": "Teilen",
|
||||||
@@ -474,7 +495,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exportImport": {
|
"exportImport": {
|
||||||
"title": "Daten Export / Import",
|
"title": "Datenexport / -import",
|
||||||
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||||
"exportTitle": "Export",
|
"exportTitle": "Export",
|
||||||
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
||||||
@@ -498,10 +519,13 @@
|
|||||||
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
||||||
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||||
"confirmButton": "Ja, alles ersetzen",
|
"confirmButton": "Ja, alles ersetzen",
|
||||||
|
"confirmImportEmpty": "Daten importieren?",
|
||||||
|
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
|
||||||
|
"confirmButtonEmpty": "Importieren",
|
||||||
"cancelButton": "Abbrechen",
|
"cancelButton": "Abbrechen",
|
||||||
"exportSuccess": "Daten erfolgreich exportiert",
|
"exportSuccess": "Daten erfolgreich exportiert",
|
||||||
"importSuccess": "Daten erfolgreich importiert",
|
"importSuccess": "Daten erfolgreich importiert",
|
||||||
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
|
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
|
||||||
"importError": "Daten konnten nicht importiert werden",
|
"importError": "Daten konnten nicht importiert werden",
|
||||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
|
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
|
||||||
"downloadFilename": "medassist-export"
|
"downloadFilename": "medassist-export"
|
||||||
@@ -535,11 +559,19 @@
|
|||||||
},
|
},
|
||||||
"editStock": {
|
"editStock": {
|
||||||
"title": "Bestand korrigieren",
|
"title": "Bestand korrigieren",
|
||||||
|
"buttonLabel": "Bestand/Angebrochene Blister korrigieren",
|
||||||
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
||||||
"totalPills": "Gesamte Tabletten",
|
"totalPills": "Gesamte Tabletten",
|
||||||
"fullBlisters": "Volle Blister",
|
"fullBlisters": "Volle Blister",
|
||||||
"partialBlisterPills": "Angebrochener Blister",
|
"partialBlisterPills": "Angebrochener Blister",
|
||||||
|
"loosePills": "Lose Tabletten",
|
||||||
"pillsPerBlister": "(je {{count}} Tabletten)",
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
|
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
||||||
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
||||||
|
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
||||||
|
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
||||||
|
"decreaseValue": "Wert verringern",
|
||||||
|
"increaseValue": "Wert erhöhen",
|
||||||
"currentTotal": "Aktueller Bestand",
|
"currentTotal": "Aktueller Bestand",
|
||||||
"newTotal": "Neuer Bestand",
|
"newTotal": "Neuer Bestand",
|
||||||
"difference": "Differenz",
|
"difference": "Differenz",
|
||||||
@@ -564,5 +596,68 @@
|
|||||||
"copyright": "© {{year}} Daniel Volz",
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
||||||
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"button": "Bericht",
|
||||||
|
"title": "Medikamentenbericht",
|
||||||
|
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"deselectAll": "Alle abwählen",
|
||||||
|
"activeMeds": "Aktive Medikamente",
|
||||||
|
"obsoleteMeds": "Obsolete Medikamente",
|
||||||
|
"format": "Format",
|
||||||
|
"formatTxt": "Klartext (.txt)",
|
||||||
|
"formatMd": "Markdown (.md)",
|
||||||
|
"formatPdf": "PDF (Drucken)",
|
||||||
|
"generate": "Erstellen",
|
||||||
|
"generating": "Wird erstellt...",
|
||||||
|
"noSelection": "Wähle mindestens ein Medikament aus",
|
||||||
|
"filterByPerson": "Bericht für",
|
||||||
|
"allPeople": "Alle Personen",
|
||||||
|
"docTitle": "Medikamentenbericht",
|
||||||
|
"docGenerated": "Erstellt am",
|
||||||
|
"docGeneral": "Allgemein",
|
||||||
|
"docCommercialName": "Handelsname",
|
||||||
|
"docGenericName": "Wirkstoff",
|
||||||
|
"docTakenBy": "Eingenommen von",
|
||||||
|
"docStartDate": "Startdatum",
|
||||||
|
"docObsoleteSince": "Obsolet seit",
|
||||||
|
"docStatus": "Status",
|
||||||
|
"docStatusActive": "Aktiv",
|
||||||
|
"docStatusObsolete": "Obsolet",
|
||||||
|
"docPackage": "Verpackung",
|
||||||
|
"docPackageType": "Verpackungsart",
|
||||||
|
"docBlister": "Blisterpackung",
|
||||||
|
"docBottle": "Pillendose",
|
||||||
|
"docPacks": "Packungen",
|
||||||
|
"docBlistersPerPack": "Blister pro Packung",
|
||||||
|
"docPillsPerBlister": "Tabletten pro Blister",
|
||||||
|
"docTotalCapacity": "Gesamtkapazität",
|
||||||
|
"docCurrentStock": "Aktueller Bestand",
|
||||||
|
"docLoosePills": "Lose Tabletten",
|
||||||
|
"docDose": "Dosis",
|
||||||
|
"docDosePerPill": "Dosis pro Tablette",
|
||||||
|
"docExpiryDate": "Ablaufdatum",
|
||||||
|
"docNotes": "Notizen",
|
||||||
|
"docIntakeSchedule": "Einnahmeplan",
|
||||||
|
"docIntakeEntry": "{{usage}} Tablette(n) alle {{every}} Tag(e) ab {{start}}",
|
||||||
|
"docIntakeTakenBy": "eingenommen von {{person}}",
|
||||||
|
"docIntakeReminder": "Erinnerung aktiv",
|
||||||
|
"docPrescription": "Rezept",
|
||||||
|
"docAuthorizedRefills": "Genehmigte Nachfüllungen",
|
||||||
|
"docRemainingRefills": "Verbleibende Nachfüllungen",
|
||||||
|
"docPrescriptionExpiry": "Rezeptablauf",
|
||||||
|
"docIntakeHistory": "Einnahme-Verlauf",
|
||||||
|
"docDosesTaken": "Eingenommene Dosen",
|
||||||
|
"docDosesTakenAutomatic": "Automatisch eingenommen",
|
||||||
|
"docDosesDismissed": "Verworfene Dosen",
|
||||||
|
"docFirstDose": "Erste Dosis",
|
||||||
|
"docLastDose": "Letzte Dosis",
|
||||||
|
"docRefillHistory": "Nachfüll-Verlauf",
|
||||||
|
"docRefillEntry": "{{date}}: +{{packs}} Packungen, +{{loose}} Tabletten",
|
||||||
|
"docRefillPrescription": "(Rezept-Nachfüllung)",
|
||||||
|
"docNoRefills": "Keine Nachfüllungen erfasst",
|
||||||
|
"docNoDoses": "Keine Dosen erfasst",
|
||||||
|
"docPrintInstruction": "Nutze die Druckfunktion deines Browsers (Strg+P / ⌘P) um als PDF zu speichern."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-16
@@ -70,10 +70,10 @@
|
|||||||
"inDays_one": "in {{days}} day",
|
"inDays_one": "in {{days}} day",
|
||||||
"inDays_other": "in {{days}} days",
|
"inDays_other": "in {{days}} days",
|
||||||
"noRemindersNeeded": "No reminders needed",
|
"noRemindersNeeded": "No reminders needed",
|
||||||
"needRefill": "{{count}} med needs refill",
|
"needRefill": "{{count}} medication needs refill",
|
||||||
"needRefill_other": "{{count}} meds need refill",
|
"needRefill_other": "{{count}} medications need refill",
|
||||||
"emptyStock": "{{count}} med is empty",
|
"emptyStock": "{{count}} medication is empty",
|
||||||
"emptyStock_other": "{{count}} meds are empty",
|
"emptyStock_other": "{{count}} medications are empty",
|
||||||
"lowWarning": "{{count}} medication running critically low",
|
"lowWarning": "{{count}} medication running critically low",
|
||||||
"lowWarning_other": "{{count}} medications running critically low",
|
"lowWarning_other": "{{count}} medications running critically low",
|
||||||
"waitingFirstCheck": "Waiting for first check",
|
"waitingFirstCheck": "Waiting for first check",
|
||||||
@@ -84,10 +84,10 @@
|
|||||||
"channelEmail": "Email",
|
"channelEmail": "Email",
|
||||||
"channelPush": "Push",
|
"channelPush": "Push",
|
||||||
"channelBoth": "Email + Push",
|
"channelBoth": "Email + Push",
|
||||||
"criticalMeds": "{{count}} medication critical",
|
"criticalMeds": "{{count}} medication is critical",
|
||||||
"criticalMeds_other": "{{count}} medications critical",
|
"criticalMeds_other": "{{count}} medications are critical",
|
||||||
"lowMeds": "{{count}} medication low",
|
"lowMeds": "{{count}} medication is low",
|
||||||
"lowMeds_other": "{{count}} medications low",
|
"lowMeds_other": "{{count}} medications are low",
|
||||||
"prescriptionNeeds": "Prescription low",
|
"prescriptionNeeds": "Prescription low",
|
||||||
"prescriptionLowMeds": "{{count}} prescription low",
|
"prescriptionLowMeds": "{{count}} prescription low",
|
||||||
"prescriptionLowMeds_other": "{{count}} prescriptions low",
|
"prescriptionLowMeds_other": "{{count}} prescriptions low",
|
||||||
@@ -152,14 +152,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Edit medication",
|
"editEntry": "Edit",
|
||||||
"viewEntry": "View medication",
|
"editEntryWithName": "Edit: {{name}}",
|
||||||
|
"viewEntry": "View",
|
||||||
"newEntry": "New medication",
|
"newEntry": "New medication",
|
||||||
"badge": "Packs + loose pills",
|
"badge": "Packs + loose pills",
|
||||||
"sections": {
|
"sections": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"stock": "Stock & Dose",
|
"stock": "Package",
|
||||||
"prescription": "Prescription"
|
"prescription": "Prescription",
|
||||||
|
"prescriptionAndRefill": "Rx & Refill",
|
||||||
|
"schedule": "Schedule"
|
||||||
},
|
},
|
||||||
"commercialName": "Commercial Name",
|
"commercialName": "Commercial Name",
|
||||||
"genericName": "Generic Name",
|
"genericName": "Generic Name",
|
||||||
@@ -291,6 +294,18 @@
|
|||||||
"shareStockStatus": "Show Stock on Shared Links",
|
"shareStockStatus": "Show Stock on Shared Links",
|
||||||
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
|
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
|
||||||
},
|
},
|
||||||
|
"timeline": {
|
||||||
|
"title": "General UI",
|
||||||
|
"upcomingSection": "Upcoming Schedule",
|
||||||
|
"upcomingTodayOnly": "Show only today",
|
||||||
|
"upcomingTodayOnlyDesc": "Hide past and future days and show only today's schedule on the dashboard.",
|
||||||
|
"dashboardSectionOrder": "Dashboard Layout",
|
||||||
|
"swapDashboardSections": "Show Upcoming Schedules before Medication Overview",
|
||||||
|
"swapDashboardSectionsDesc": "When enabled, the dashboard prioritizes the upcoming schedule section above the medication overview section.",
|
||||||
|
"sharedSection": "Shared Schedule",
|
||||||
|
"shareScheduleTodayOnly": "Shared links show only today",
|
||||||
|
"shareScheduleTodayOnlyDesc": "Hide past and future days on shared schedule links and show only today's entries."
|
||||||
|
},
|
||||||
"stockReminder": {
|
"stockReminder": {
|
||||||
"title": "Stock Reminder",
|
"title": "Stock Reminder",
|
||||||
"description": "Enable stock reminders",
|
"description": "Enable stock reminders",
|
||||||
@@ -336,6 +351,7 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Intake reminders enabled",
|
"intakeReminders": "Intake reminders enabled",
|
||||||
|
"automaticTaken": "Automatically taken",
|
||||||
"hasNotes": "Has notes",
|
"hasNotes": "Has notes",
|
||||||
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
||||||
"lightMode": "Switch to light mode",
|
"lightMode": "Switch to light mode",
|
||||||
@@ -349,7 +365,8 @@
|
|||||||
},
|
},
|
||||||
"dose": {
|
"dose": {
|
||||||
"takenBy": "taken by",
|
"takenBy": "taken by",
|
||||||
"markAsTaken": "Mark as taken"
|
"markAsTaken": "Mark as taken",
|
||||||
|
"take": "Take"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
@@ -414,7 +431,7 @@
|
|||||||
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
||||||
"tooLong": "{{current}}/{{max}} characters"
|
"tooLong": "{{current}}/{{max}} characters"
|
||||||
},
|
},
|
||||||
"saved": "Saved ✓",
|
"saved": "Saved",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -434,6 +451,8 @@
|
|||||||
"of": "of",
|
"of": "of",
|
||||||
"loose": "loose",
|
"loose": "loose",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
|
"daily": "daily",
|
||||||
|
"everyNDays": "every {{count}} days",
|
||||||
"day": "day",
|
"day": "day",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
"blister": "blister",
|
"blister": "blister",
|
||||||
@@ -445,7 +464,9 @@
|
|||||||
"pillsTotal": "{{count}} pills total",
|
"pillsTotal": "{{count}} pills total",
|
||||||
"pillsTotal_one": "{{count}} pill total",
|
"pillsTotal_one": "{{count}} pill total",
|
||||||
"pillsTotal_other": "{{count}} pills total",
|
"pillsTotal_other": "{{count}} pills total",
|
||||||
"max": "max"
|
"max": "max",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Share",
|
"button": "Share",
|
||||||
@@ -498,10 +519,13 @@
|
|||||||
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
||||||
"confirmImportWarning": "This action cannot be undone!",
|
"confirmImportWarning": "This action cannot be undone!",
|
||||||
"confirmButton": "Yes, Replace All",
|
"confirmButton": "Yes, Replace All",
|
||||||
|
"confirmImportEmpty": "Import Data?",
|
||||||
|
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
|
||||||
|
"confirmButtonEmpty": "Import",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
"exportSuccess": "Data exported successfully",
|
"exportSuccess": "Data exported successfully",
|
||||||
"importSuccess": "Data imported successfully",
|
"importSuccess": "Data imported successfully",
|
||||||
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
|
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
|
||||||
"importError": "Failed to import data",
|
"importError": "Failed to import data",
|
||||||
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
|
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
|
||||||
"downloadFilename": "medassist-export"
|
"downloadFilename": "medassist-export"
|
||||||
@@ -535,11 +559,19 @@
|
|||||||
},
|
},
|
||||||
"editStock": {
|
"editStock": {
|
||||||
"title": "Correct Stock",
|
"title": "Correct Stock",
|
||||||
|
"buttonLabel": "Correct Stock/Partial Blister",
|
||||||
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
||||||
"totalPills": "Total pills",
|
"totalPills": "Total pills",
|
||||||
"fullBlisters": "Full blisters",
|
"fullBlisters": "Full blisters",
|
||||||
"partialBlisterPills": "Partial blister",
|
"partialBlisterPills": "Partial blister",
|
||||||
|
"loosePills": "Loose pills",
|
||||||
"pillsPerBlister": "({{count}} pills each)",
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
|
"packageSize": "Package size: {{count}} pills",
|
||||||
|
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
||||||
|
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
||||||
|
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
||||||
|
"decreaseValue": "Decrease value",
|
||||||
|
"increaseValue": "Increase value",
|
||||||
"currentTotal": "Current total",
|
"currentTotal": "Current total",
|
||||||
"newTotal": "New total",
|
"newTotal": "New total",
|
||||||
"difference": "Difference",
|
"difference": "Difference",
|
||||||
@@ -564,5 +596,68 @@
|
|||||||
"copyright": "© {{year}} Daniel Volz",
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
"madeWith": "Made with ❤️ for better health management",
|
"madeWith": "Made with ❤️ for better health management",
|
||||||
"techStack": "Built with React, Fastify & SQLite"
|
"techStack": "Built with React, Fastify & SQLite"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"button": "Report",
|
||||||
|
"title": "Medication Report",
|
||||||
|
"description": "Generate a document with detailed medication information for your doctor or personal records.",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"activeMeds": "Active Medications",
|
||||||
|
"obsoleteMeds": "Obsolete Medications",
|
||||||
|
"format": "Format",
|
||||||
|
"formatTxt": "Plain Text (.txt)",
|
||||||
|
"formatMd": "Markdown (.md)",
|
||||||
|
"formatPdf": "PDF (Print)",
|
||||||
|
"generate": "Generate",
|
||||||
|
"generating": "Generating...",
|
||||||
|
"noSelection": "Select at least one medication",
|
||||||
|
"filterByPerson": "Report for",
|
||||||
|
"allPeople": "Everyone",
|
||||||
|
"docTitle": "Medication Report",
|
||||||
|
"docGenerated": "Generated on",
|
||||||
|
"docGeneral": "General",
|
||||||
|
"docCommercialName": "Commercial Name",
|
||||||
|
"docGenericName": "Generic Name",
|
||||||
|
"docTakenBy": "Taken by",
|
||||||
|
"docStartDate": "Start Date",
|
||||||
|
"docObsoleteSince": "Obsolete Since",
|
||||||
|
"docStatus": "Status",
|
||||||
|
"docStatusActive": "Active",
|
||||||
|
"docStatusObsolete": "Obsolete",
|
||||||
|
"docPackage": "Package",
|
||||||
|
"docPackageType": "Package Type",
|
||||||
|
"docBlister": "Blister Pack",
|
||||||
|
"docBottle": "Pill Bottle",
|
||||||
|
"docPacks": "Packs",
|
||||||
|
"docBlistersPerPack": "Blisters per pack",
|
||||||
|
"docPillsPerBlister": "Pills per blister",
|
||||||
|
"docTotalCapacity": "Total capacity",
|
||||||
|
"docCurrentStock": "Current stock",
|
||||||
|
"docLoosePills": "Loose pills",
|
||||||
|
"docDose": "Dose",
|
||||||
|
"docDosePerPill": "Dose per pill",
|
||||||
|
"docExpiryDate": "Expiry Date",
|
||||||
|
"docNotes": "Notes",
|
||||||
|
"docIntakeSchedule": "Intake Schedule",
|
||||||
|
"docIntakeEntry": "{{usage}} pill(s) every {{every}} day(s) from {{start}}",
|
||||||
|
"docIntakeTakenBy": "taken by {{person}}",
|
||||||
|
"docIntakeReminder": "reminder enabled",
|
||||||
|
"docPrescription": "Prescription",
|
||||||
|
"docAuthorizedRefills": "Authorized refills",
|
||||||
|
"docRemainingRefills": "Remaining refills",
|
||||||
|
"docPrescriptionExpiry": "Prescription expiry",
|
||||||
|
"docIntakeHistory": "Intake History",
|
||||||
|
"docDosesTaken": "Doses taken",
|
||||||
|
"docDosesTakenAutomatic": "Automatically taken",
|
||||||
|
"docDosesDismissed": "Doses dismissed",
|
||||||
|
"docFirstDose": "First dose",
|
||||||
|
"docLastDose": "Last dose",
|
||||||
|
"docRefillHistory": "Refill History",
|
||||||
|
"docRefillEntry": "{{date}}: +{{packs}} packs, +{{loose}} pills",
|
||||||
|
"docRefillPrescription": "(prescription refill)",
|
||||||
|
"docNoRefills": "No refills recorded",
|
||||||
|
"docNoDoses": "No doses recorded",
|
||||||
|
"docPrintInstruction": "Use your browser's Print function (Ctrl+P / ⌘P) to save as PDF."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import "./styles/modals-base.css";
|
||||||
|
import "./styles/share-dialog.css";
|
||||||
|
import "./styles/medication-workflows.css";
|
||||||
|
import "./styles/schedule-mobile-edit.css";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -1,59 +1,21 @@
|
|||||||
|
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||||
|
import { Bell, NotebookPen, Share2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import { useModalHistory } from "../hooks";
|
||||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||||
|
import {
|
||||||
// Helper for user-specific localStorage keys
|
formatFullBlisters,
|
||||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
formatOpenBlisterAndLoose,
|
||||||
return userId ? `user_${userId}_${key}` : key;
|
getBlisterStock,
|
||||||
}
|
getMedTotal,
|
||||||
|
getReminderStatusData,
|
||||||
// Helper function to calculate blister stock
|
userStorageKey,
|
||||||
export function getBlisterStock(
|
} from "./dashboard-helpers";
|
||||||
totalPills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
_looseTablets: number,
|
|
||||||
_originalTotal: number
|
|
||||||
) {
|
|
||||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
|
||||||
const openBlisterPills = totalPills % pillsPerBlister;
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format full blisters
|
|
||||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
|
||||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format open blister and loose pills
|
|
||||||
export function formatOpenBlisterAndLoose(
|
|
||||||
openBlisterPills: number,
|
|
||||||
loosePills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
t: (key: string) => string
|
|
||||||
): string {
|
|
||||||
if (openBlisterPills === 0 && loosePills === 0) return "-";
|
|
||||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total pills for a medication (packageType-aware)
|
|
||||||
export function getMedTotal(med: {
|
|
||||||
packCount: number;
|
|
||||||
blistersPerPack: number;
|
|
||||||
pillsPerBlister: number;
|
|
||||||
looseTablets: number;
|
|
||||||
stockAdjustment?: number | null;
|
|
||||||
packageType?: string;
|
|
||||||
}): number {
|
|
||||||
if (med.packageType === "bottle") {
|
|
||||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification bell SVG icon (no emoji)
|
// Notification bell SVG icon (no emoji)
|
||||||
function NotificationBellIcon() {
|
function NotificationBellIcon() {
|
||||||
@@ -75,108 +37,6 @@ function NotificationBellIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get structured reminder status data
|
|
||||||
export function getReminderStatusData(
|
|
||||||
reminderDaysBefore: number,
|
|
||||||
lowStockDays: number,
|
|
||||||
_allLowCoverage: Coverage[],
|
|
||||||
allCoverage: Coverage[],
|
|
||||||
lastAutoEmailSent: string | null,
|
|
||||||
lastNotificationType: string | null,
|
|
||||||
_lastNotificationChannel: string | null,
|
|
||||||
lastReminderMedName: string | null,
|
|
||||||
lastReminderTakenBy: string | null,
|
|
||||||
lastStockReminderSent: string | null,
|
|
||||||
_lastStockReminderChannel: string | null,
|
|
||||||
lastStockReminderMedNames: string | null,
|
|
||||||
t: (key: string, options?: Record<string, unknown>) => string,
|
|
||||||
locale: string
|
|
||||||
): {
|
|
||||||
status: { text: string; className: string };
|
|
||||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
|
||||||
lastStockSent: { date: string; medNames: string | null } | null;
|
|
||||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
|
||||||
} {
|
|
||||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
|
||||||
|
|
||||||
for (const c of allCoverage) {
|
|
||||||
if (c.medsLeft <= 0) {
|
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.daysLeft === null) continue;
|
|
||||||
|
|
||||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
|
||||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
|
||||||
const isLow = c.daysLeft < lowStockDays;
|
|
||||||
if (!isCritical && !isLow) continue;
|
|
||||||
|
|
||||||
const existing = lowStockMap.get(c.name);
|
|
||||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
|
||||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
|
||||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
|
||||||
|
|
||||||
// Determine status
|
|
||||||
let status: { text: string; className: string };
|
|
||||||
if (criticalCount > 0) {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
|
||||||
className: "danger",
|
|
||||||
};
|
|
||||||
} else if (lowCount > 0) {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
|
||||||
className: "warning",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.allOk"),
|
|
||||||
className: "success",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse last stock reminder sent info (from dedicated stock tracking columns)
|
|
||||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
|
||||||
if (lastStockReminderSent) {
|
|
||||||
const sentDate = new Date(lastStockReminderSent);
|
|
||||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
lastStockSent = {
|
|
||||||
date: formattedDate,
|
|
||||||
medNames: lastStockReminderMedNames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse last intake reminder sent info (from intake tracking columns)
|
|
||||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
|
||||||
if (lastAutoEmailSent) {
|
|
||||||
const sentDate = new Date(lastAutoEmailSent);
|
|
||||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
lastIntakeSent = {
|
|
||||||
date: formattedDate,
|
|
||||||
medName: lastReminderMedName,
|
|
||||||
takenBy: lastReminderTakenBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -205,6 +65,7 @@ export function DashboardPage() {
|
|||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
getDayStockStatus,
|
getDayStockStatus,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
clearingMissed,
|
clearingMissed,
|
||||||
@@ -217,6 +78,8 @@ export function DashboardPage() {
|
|||||||
loadSettings,
|
loadSettings,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
|
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
|
||||||
|
|
||||||
// Get structured reminder data
|
// Get structured reminder data
|
||||||
const reminderData = getReminderStatusData(
|
const reminderData = getReminderStatusData(
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
@@ -262,6 +125,7 @@ export function DashboardPage() {
|
|||||||
.sort((a, b) => a.remainingRefills - b.remainingRefills);
|
.sort((a, b) => a.remainingRefills - b.remainingRefills);
|
||||||
|
|
||||||
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
|
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled || prescriptionRemindersEnabled;
|
||||||
|
const showOnlyToday = settings.upcomingTodayOnly;
|
||||||
|
|
||||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||||
const prescriptionStatus =
|
const prescriptionStatus =
|
||||||
@@ -401,6 +265,11 @@ export function DashboardPage() {
|
|||||||
<span
|
<span
|
||||||
className={`med-link clickable ${textClass}`}
|
className={`med-link clickable ${textClass}`}
|
||||||
onClick={() => medication && openMedDetail(medication)}
|
onClick={() => medication && openMedDetail(medication)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (medication) openMedDetail(medication);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{med.name}
|
{med.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -430,6 +299,11 @@ export function DashboardPage() {
|
|||||||
<span
|
<span
|
||||||
className={`med-link clickable ${textClass}`}
|
className={`med-link clickable ${textClass}`}
|
||||||
onClick={() => medication && openMedDetail(medication)}
|
onClick={() => medication && openMedDetail(medication)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (medication) openMedDetail(medication);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{med.name}
|
{med.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -453,7 +327,13 @@ export function DashboardPage() {
|
|||||||
<span key={name}>
|
<span key={name}>
|
||||||
{idx > 0 && ", "}
|
{idx > 0 && ", "}
|
||||||
{medication ? (
|
{medication ? (
|
||||||
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
|
<span
|
||||||
|
className="med-link clickable"
|
||||||
|
onClick={() => openMedDetail(medication)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -475,7 +355,13 @@ export function DashboardPage() {
|
|||||||
(() => {
|
(() => {
|
||||||
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
|
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
|
||||||
return medication ? (
|
return medication ? (
|
||||||
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
|
<span
|
||||||
|
className="med-link clickable"
|
||||||
|
onClick={() => openMedDetail(medication)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openMedDetail(medication);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{reminderData.lastIntakeSent!.medName}
|
{reminderData.lastIntakeSent!.medName}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -553,7 +439,15 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<span key={c.name}>
|
<span key={c.name}>
|
||||||
{idx > 0 && ", "}
|
{idx > 0 && ", "}
|
||||||
<span className={`med-link clickable ${textClass}`} onClick={() => med && openMedDetail(med)}>
|
<span
|
||||||
|
className={`med-link clickable ${textClass}`}
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
</span>
|
</span>
|
||||||
<span className={`reminder-days-left ${textClass}`}>
|
<span className={`reminder-days-left ${textClass}`}>
|
||||||
@@ -571,7 +465,10 @@ export function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="grid">
|
<div
|
||||||
|
className={`dashboard-main-sections${settings.swapDashboardMainSections ? " dashboard-main-sections-swapped" : ""}`}
|
||||||
|
>
|
||||||
|
<section className="grid dashboard-overview-section">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("dashboard.overview.title")}</h2>
|
<h2>{t("dashboard.overview.title")}</h2>
|
||||||
@@ -603,12 +500,45 @@ export function DashboardPage() {
|
|||||||
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
<div
|
||||||
|
key={row.name}
|
||||||
|
className="table-row clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||||
<span className="med-name-line">
|
<span className="med-name-line">
|
||||||
|
<span
|
||||||
|
className={med?.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||||
|
</span>
|
||||||
<span className="med-name-block-dash">
|
<span className="med-name-block-dash">
|
||||||
<span className="med-name-text">{row.name}</span>
|
<span className="med-name-text">
|
||||||
|
{row.name}
|
||||||
|
{med?.notes && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||||
|
<NotebookPen size={13} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{med?.takenBy && med.takenBy.length > 0 && (
|
{med?.takenBy && med.takenBy.length > 0 && (
|
||||||
<span className="med-taken-by-line">
|
<span className="med-taken-by-line">
|
||||||
{med.takenBy.map((person) => (
|
{med.takenBy.map((person) => (
|
||||||
@@ -619,22 +549,28 @@ export function DashboardPage() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openUserFilter(person);
|
openUserFilter(person);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.stopPropagation();
|
||||||
|
openUserFilter(person);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{person}
|
{person}
|
||||||
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"}
|
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && (
|
||||||
|
<Bell
|
||||||
|
size={11}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="blister-reminder-icon"
|
||||||
|
style={{ display: "inline", verticalAlign: "middle", marginLeft: "2px" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{med?.notes && (
|
|
||||||
<span className="med-icons">
|
|
||||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
|
||||||
📝
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("table.stock")} className={textClass}>
|
<span data-label={t("table.stock")} className={textClass}>
|
||||||
{med?.packageType === "bottle"
|
{med?.packageType === "bottle"
|
||||||
@@ -677,16 +613,11 @@ export function DashboardPage() {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid">
|
<section className="grid dashboard-schedules-section">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("dashboard.schedules.title")}</h2>
|
<h2>{t("dashboard.schedules.title")}</h2>
|
||||||
<div className="card-head-actions">
|
<div className="card-head-actions">
|
||||||
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
|
|
||||||
<button className="ghost share-btn" onClick={openShareDialog} title={t("share.button")}>
|
|
||||||
🔗 {t("share.button")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<select
|
<select
|
||||||
className="schedule-days-select"
|
className="schedule-days-select"
|
||||||
value={scheduleDays}
|
value={scheduleDays}
|
||||||
@@ -700,11 +631,22 @@ export function DashboardPage() {
|
|||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
|
||||||
|
<button
|
||||||
|
className="ghost share-btn icon-only tooltip-trigger"
|
||||||
|
onClick={openShareDialog}
|
||||||
|
aria-label={t("share.button")}
|
||||||
|
data-tooltip={t("share.button")}
|
||||||
|
>
|
||||||
|
<Share2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{/* Past days (when expanded) — rendered above toggle */}
|
{/* Past days (when expanded) — rendered above toggle */}
|
||||||
{showPastDays &&
|
{!showOnlyToday &&
|
||||||
|
showPastDays &&
|
||||||
pastDays.map((day) => {
|
pastDays.map((day) => {
|
||||||
// Get ALL dose IDs for this day (for total count and yellow styling)
|
// Get ALL dose IDs for this day (for total count and yellow styling)
|
||||||
const allDoseIds = day.meds.flatMap((item) =>
|
const allDoseIds = day.meds.flatMap((item) =>
|
||||||
@@ -737,7 +679,7 @@ export function DashboardPage() {
|
|||||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
const worstStatus = getDayStockStatus(day.meds);
|
const _worstStatus = getDayStockStatus(day.meds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -747,6 +689,9 @@ export function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -782,16 +727,35 @@ export function DashboardPage() {
|
|||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -808,28 +772,36 @@ export function DashboardPage() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={13} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
onClick={() => openUserFilter(person)}
|
onClick={() => openUserFilter(person)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
@@ -840,6 +812,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -849,7 +829,8 @@ export function DashboardPage() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -867,7 +848,8 @@ export function DashboardPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Past days toggle */}
|
{/* Past days toggle */}
|
||||||
{pastDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
pastDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const missedCount = missedPastDoseIds.length;
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
|
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
|
||||||
@@ -886,6 +868,19 @@ export function DashboardPage() {
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
const wasCollapsed = !showPastDays;
|
||||||
|
setShowPastDays(!showPastDays);
|
||||||
|
if (wasCollapsed) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document
|
||||||
|
.querySelector(".day-block.today")
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||||
<span className="past-days-label">
|
<span className="past-days-label">
|
||||||
@@ -960,6 +955,9 @@ export function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -989,16 +987,35 @@ export function DashboardPage() {
|
|||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1019,28 +1036,36 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={13} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
onClick={() => openUserFilter(person)}
|
onClick={() => openUserFilter(person)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
@@ -1051,6 +1076,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1060,7 +1093,8 @@ export function DashboardPage() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1078,7 +1112,8 @@ export function DashboardPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* Future days toggle */}
|
{/* Future days toggle */}
|
||||||
{futureDays.length > 0 &&
|
{!showOnlyToday &&
|
||||||
|
futureDays.length > 0 &&
|
||||||
(() => {
|
(() => {
|
||||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) =>
|
d.meds.flatMap((m) =>
|
||||||
@@ -1093,6 +1128,9 @@ export function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
||||||
onClick={() => setShowFutureDays(!showFutureDays)}
|
onClick={() => setShowFutureDays(!showFutureDays)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
||||||
<span className="future-days-label">
|
<span className="future-days-label">
|
||||||
@@ -1113,7 +1151,8 @@ export function DashboardPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* Future days */}
|
{/* Future days */}
|
||||||
{showFutureDays &&
|
{!showOnlyToday &&
|
||||||
|
showFutureDays &&
|
||||||
futureDays.map((day) => {
|
futureDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
|
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||||
@@ -1147,6 +1186,9 @@ export function DashboardPage() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -1176,16 +1218,35 @@ export function DashboardPage() {
|
|||||||
const itemDoseIds = expandDoseIds(item.doses);
|
const itemDoseIds = expandDoseIds(item.doses);
|
||||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||||
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
onClick={() => med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="med-name-stack clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span className="med-name-text">{item.medName}</span>
|
<span className="med-name-text">{item.medName}</span>
|
||||||
|
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tag-row">
|
<div className="tag-row">
|
||||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||||
@@ -1202,28 +1263,36 @@ export function DashboardPage() {
|
|||||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg &&
|
</span>
|
||||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={13} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
onClick={() => openUserFilter(person)}
|
onClick={() => openUserFilter(person)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
@@ -1234,6 +1303,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1243,7 +1320,8 @@ export function DashboardPage() {
|
|||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1263,6 +1341,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Clear Missed Doses Confirmation Modal */}
|
{/* Clear Missed Doses Confirmation Modal */}
|
||||||
{showClearMissedConfirm && (
|
{showClearMissedConfirm && (
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
|
||||||
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
|
||||||
|
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
|
||||||
|
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal } from "../components";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, Medication } from "../types";
|
import type { DoseUnit, Medication } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
@@ -16,6 +21,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
|
||||||
|
|
||||||
export function MedicationsPage() {
|
export function MedicationsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const {
|
const {
|
||||||
@@ -28,14 +34,6 @@ export function MedicationsPage() {
|
|||||||
deleteMedImage,
|
deleteMedImage,
|
||||||
uploadingImage,
|
uploadingImage,
|
||||||
existingPeople,
|
existingPeople,
|
||||||
refillPacks,
|
|
||||||
setRefillPacks,
|
|
||||||
refillLoose,
|
|
||||||
setRefillLoose,
|
|
||||||
usePrescriptionRefill,
|
|
||||||
setUsePrescriptionRefill,
|
|
||||||
refillSaving,
|
|
||||||
submitRefill,
|
|
||||||
coverageByMed,
|
coverageByMed,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
@@ -72,9 +70,16 @@ export function MedicationsPage() {
|
|||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">("grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
|
|
||||||
// Mobile modal state (declared early because it's used in useEffect below)
|
// Mobile modal state (declared early because it's used in useEffect below)
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const showEditModalRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
showEditModalRef.current = showEditModal;
|
||||||
|
}, [showEditModal]);
|
||||||
|
const processedEditMedIdRef = useRef<string | null>(null);
|
||||||
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
// Sync formChanged state to the global context for navigation blocking
|
// Sync formChanged state to the global context for navigation blocking
|
||||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||||
@@ -96,6 +101,18 @@ export function MedicationsPage() {
|
|||||||
}
|
}
|
||||||
}, [formChanged, showEditModal]);
|
}, [formChanged, showEditModal]);
|
||||||
|
|
||||||
|
// Push a history state when desktop form is open so browser back returns to grid view.
|
||||||
|
useEffect(() => {
|
||||||
|
const isDesktop = window.innerWidth > 768;
|
||||||
|
if (isDesktop && viewMode === "form" && !showEditModal && !hasDesktopFormHistoryState.current) {
|
||||||
|
window.history.pushState({ desktopForm: true }, "");
|
||||||
|
hasDesktopFormHistoryState.current = true;
|
||||||
|
}
|
||||||
|
if ((viewMode === "grid" || showEditModal) && hasDesktopFormHistoryState.current) {
|
||||||
|
hasDesktopFormHistoryState.current = false;
|
||||||
|
}
|
||||||
|
}, [viewMode, showEditModal]);
|
||||||
|
|
||||||
// Image state for new medications
|
// Image state for new medications
|
||||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||||
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
const [pendingImagePreview, setPendingImagePreview] = useState<string | null>(null);
|
||||||
@@ -113,6 +130,9 @@ export function MedicationsPage() {
|
|||||||
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
|
||||||
const [showObsolete, setShowObsolete] = useState(true);
|
const [showObsolete, setShowObsolete] = useState(true);
|
||||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||||
|
const [showReportModal, setShowReportModal] = useState(false);
|
||||||
|
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||||
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY));
|
const saved = localStorage.getItem(userStorageKey(user?.id, OBSOLETE_SECTION_STORAGE_KEY));
|
||||||
@@ -153,8 +173,7 @@ export function MedicationsPage() {
|
|||||||
const packCount = Number(form.packCount) || 0;
|
const packCount = Number(form.packCount) || 0;
|
||||||
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
const blistersPerPack = Number(form.blistersPerPack) || 0;
|
||||||
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
|
||||||
const looseTablets = Number(form.looseTablets) || 0;
|
return packCount * blistersPerPack * pillsPerBlister;
|
||||||
return packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
|
||||||
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
|
||||||
|
|
||||||
const dateConsistencyError = useMemo(() => {
|
const dateConsistencyError = useMemo(() => {
|
||||||
@@ -170,8 +189,22 @@ export function MedicationsPage() {
|
|||||||
});
|
});
|
||||||
}, [form.medicationStartDate, form.intakes, t]);
|
}, [form.medicationStartDate, form.intakes, t]);
|
||||||
|
|
||||||
|
const clearEditMedIdParam = useCallback(() => {
|
||||||
|
setSearchParams(
|
||||||
|
(prevParams) => {
|
||||||
|
if (!prevParams.has("editMedId")) return prevParams;
|
||||||
|
const nextParams = new URLSearchParams(prevParams);
|
||||||
|
nextParams.delete("editMedId");
|
||||||
|
return nextParams;
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
// Open mobile edit modal
|
// Open mobile edit modal
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
|
if (showEditModalRef.current) return;
|
||||||
|
showEditModalRef.current = true;
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
window.history.pushState({ modal: "edit" }, "");
|
window.history.pushState({ modal: "edit" }, "");
|
||||||
}
|
}
|
||||||
@@ -185,6 +218,7 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clearEditMedIdParam();
|
||||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -207,6 +241,7 @@ export function MedicationsPage() {
|
|||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
pendingAction();
|
pendingAction();
|
||||||
} else if (source === "mobile-edit" && showEditModal) {
|
} else if (source === "mobile-edit" && showEditModal) {
|
||||||
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
@@ -229,12 +264,15 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Helper to reset form and clear history state
|
// Helper to reset form and clear history state
|
||||||
function handleResetForm() {
|
function handleResetForm() {
|
||||||
|
hasDesktopFormHistoryState.current = false;
|
||||||
if (hasUnsavedHistoryState.current) {
|
if (hasUnsavedHistoryState.current) {
|
||||||
hasUnsavedHistoryState.current = false;
|
hasUnsavedHistoryState.current = false;
|
||||||
// Go back to remove the unsaved changes history entry
|
// Go back to remove the unsaved changes history entry
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
setShowNameValidation(false);
|
||||||
|
setActiveTab("general");
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setViewMode("grid");
|
setViewMode("grid");
|
||||||
}
|
}
|
||||||
@@ -296,12 +334,6 @@ export function MedicationsPage() {
|
|||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle submit refill
|
|
||||||
async function handleSubmitRefill(medId: number) {
|
|
||||||
await submitRefill(medId, editingId, setForm, loadMeds, usePrescriptionRefill);
|
|
||||||
await loadAllMeds();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markMedicationObsolete(id: number) {
|
async function markMedicationObsolete(id: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
|
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
|
||||||
@@ -330,6 +362,7 @@ export function MedicationsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (readOnlyView) return;
|
if (readOnlyView) return;
|
||||||
if (hasValidationErrors || dateConsistencyError) {
|
if (hasValidationErrors || dateConsistencyError) {
|
||||||
|
setShowNameValidation(true);
|
||||||
// Scroll to first visible error so the user sees what's wrong
|
// Scroll to first visible error so the user sees what's wrong
|
||||||
const firstError = document.querySelector(".field-error");
|
const firstError = document.querySelector(".field-error");
|
||||||
if (firstError) {
|
if (firstError) {
|
||||||
@@ -439,6 +472,20 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Reset form after successful save
|
// Reset form after successful save
|
||||||
if (!editingId) {
|
if (!editingId) {
|
||||||
|
const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768;
|
||||||
|
if (shouldCloseMobileModal) {
|
||||||
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
|
closeConfirmedRef.current = true;
|
||||||
|
clearEditMedIdParam();
|
||||||
|
setShowEditModal(false);
|
||||||
|
setReadOnlyView(false);
|
||||||
|
setActiveTab("general");
|
||||||
|
setViewMode("grid");
|
||||||
|
resetForm();
|
||||||
|
window.history.back();
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
setViewMode("grid");
|
setViewMode("grid");
|
||||||
} else {
|
} else {
|
||||||
@@ -456,6 +503,8 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
|
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
setShowObsoleteConfirm(false);
|
setShowObsoleteConfirm(false);
|
||||||
@@ -473,6 +522,11 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
|
if (currentEditMedId) {
|
||||||
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
|
clearEditMedIdParam();
|
||||||
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -491,11 +545,33 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentEditMedId) {
|
||||||
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
|
}
|
||||||
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle desktop form: browser back should return to medication overview grid.
|
||||||
|
if (viewMode === "form" && hasDesktopFormHistoryState.current) {
|
||||||
|
if (formChanged) {
|
||||||
|
window.history.pushState({ desktopForm: true }, "");
|
||||||
|
setUnsavedConfirmSource("desktop-form");
|
||||||
|
setShowUnsavedConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasDesktopFormHistoryState.current = false;
|
||||||
|
resetForm();
|
||||||
|
setShowNameValidation(false);
|
||||||
|
setActiveTab("general");
|
||||||
|
setReadOnlyView(false);
|
||||||
|
setViewMode("grid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle desktop form with unsaved changes
|
// Handle desktop form with unsaved changes
|
||||||
if (formChanged && hasUnsavedHistoryState.current) {
|
if (formChanged && hasUnsavedHistoryState.current) {
|
||||||
// Re-push history state to stay on page
|
// Re-push history state to stay on page
|
||||||
@@ -507,7 +583,7 @@ export function MedicationsPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, formChanged, resetForm]);
|
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -520,10 +596,10 @@ export function MedicationsPage() {
|
|||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [showEditModal, closeEditModal]);
|
}, [showEditModal, closeEditModal]);
|
||||||
|
|
||||||
// Handle edit button click - open modal on mobile, switch to form on desktop
|
|
||||||
function handleEditClick(med: Medication) {
|
function handleEditClick(med: Medication) {
|
||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
@@ -532,7 +608,9 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
|
setActiveTab("general");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
}
|
}
|
||||||
@@ -540,6 +618,7 @@ export function MedicationsPage() {
|
|||||||
function handleViewClick(med: Medication) {
|
function handleViewClick(med: Medication) {
|
||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
@@ -548,7 +627,9 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
|
setActiveTab("general");
|
||||||
startEdit(med, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
}
|
}
|
||||||
@@ -557,6 +638,7 @@ export function MedicationsPage() {
|
|||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
openEditModal();
|
openEditModal();
|
||||||
@@ -569,6 +651,7 @@ export function MedicationsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
openEditModal();
|
openEditModal();
|
||||||
@@ -593,6 +676,31 @@ export function MedicationsPage() {
|
|||||||
return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)];
|
return [selectedMedication, ...activeMeds.filter((med) => med.id !== editingId)];
|
||||||
}, [activeMeds, editingId]);
|
}, [activeMeds, editingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editMedId = searchParams.get("editMedId");
|
||||||
|
if (!editMedId) {
|
||||||
|
processedEditMedIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (processedEditMedIdRef.current === editMedId) return;
|
||||||
|
const parsedMedId = Number.parseInt(editMedId, 10);
|
||||||
|
if (Number.isNaN(parsedMedId)) return;
|
||||||
|
const medicationToEdit = allMeds.find((med) => med.id === parsedMedId);
|
||||||
|
if (!medicationToEdit) return;
|
||||||
|
|
||||||
|
processedEditMedIdRef.current = editMedId;
|
||||||
|
|
||||||
|
setShowNameValidation(false);
|
||||||
|
setReadOnlyView(false);
|
||||||
|
setActiveTab("general");
|
||||||
|
startEdit(medicationToEdit, openEditModal);
|
||||||
|
setViewMode("form");
|
||||||
|
|
||||||
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
|
nextParams.delete("editMedId");
|
||||||
|
setSearchParams(nextParams, { replace: true });
|
||||||
|
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
return allMeds.find((med) => med.id === editingId) ?? null;
|
return allMeds.find((med) => med.id === editingId) ?? null;
|
||||||
@@ -604,9 +712,14 @@ export function MedicationsPage() {
|
|||||||
<article className="card">
|
<article className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("medications.list.title")}</h2>
|
<h2>{t("medications.list.title")}</h2>
|
||||||
|
<div className="card-head-actions">
|
||||||
<button type="button" className="btn primary small" onClick={handleNewEntryClick}>
|
<button type="button" className="btn primary small" onClick={handleNewEntryClick}>
|
||||||
+ {t("form.newEntry")}
|
+ {t("form.newEntry")}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn ghost small" onClick={() => setShowReportModal(true)}>
|
||||||
|
{t("report.button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-groups">
|
<div className="med-groups">
|
||||||
<div className="med-group med-group-active">
|
<div className="med-group med-group-active">
|
||||||
@@ -621,6 +734,11 @@ export function MedicationsPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
||||||
}
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||||
</span>
|
</span>
|
||||||
@@ -630,17 +748,27 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
{editingId !== med.id && (
|
||||||
{t("common.delete")}
|
<button
|
||||||
|
className="info icon-only tooltip-trigger"
|
||||||
|
onClick={() => handleEditClick(med)}
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
data-tooltip={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => requestDeleteMed(med)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
data-tooltip={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
|
<button className="btn-obsolete" onClick={() => requestMarkObsolete(med)}>
|
||||||
{t("medications.list.markObsolete")}
|
{t("medications.list.markObsolete")}
|
||||||
</button>
|
</button>
|
||||||
{editingId !== med.id && (
|
|
||||||
<button className="info" onClick={() => handleEditClick(med)}>
|
|
||||||
{t("common.edit")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="med-details">
|
<div className="med-details">
|
||||||
<span>
|
<span>
|
||||||
@@ -697,14 +825,14 @@ export function MedicationsPage() {
|
|||||||
<div className="blister-list">
|
<div className="blister-list">
|
||||||
{(med.intakes ?? med.blisters).map((s, idx) => (
|
{(med.intakes ?? med.blisters).map((s, idx) => (
|
||||||
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
<div key={`${med.id}-${idx}`} className="blister-row-simple">
|
||||||
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} · {t("form.blisters.every")}{" "}
|
{s.usage} {s.usage === 1 ? t("common.pill") : t("common.pills")} ·{" "}
|
||||||
{s.every} {s.every === 1 ? t("common.day") : t("common.days")} · {t("form.blisters.from")}{" "}
|
{s.every === 1 ? t("common.daily") : t("common.everyNDays", { count: s.every })} ·{" "}
|
||||||
{formatDateTime(s.start)}
|
{t("form.blisters.from")} {formatDateTime(s.start)}
|
||||||
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
{"takenBy" in s && s.takenBy && <span className="blister-taken-by"> · {s.takenBy}</span>}
|
||||||
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
{"intakeRemindersEnabled" in s && s.intakeRemindersEnabled && (
|
||||||
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
|
||||||
{" "}
|
{" "}
|
||||||
🔔
|
<Bell size={12} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -738,6 +866,12 @@ export function MedicationsPage() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
|
||||||
}
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med.imageUrl)
|
||||||
|
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
|
||||||
</span>
|
</span>
|
||||||
@@ -747,15 +881,25 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
<button className="danger" onClick={() => requestDeleteMed(med)}>
|
<button
|
||||||
{t("common.delete")}
|
className="info icon-only tooltip-trigger"
|
||||||
|
onClick={() => handleViewClick(med)}
|
||||||
|
aria-label={t("common.view")}
|
||||||
|
data-tooltip={t("common.view")}
|
||||||
|
>
|
||||||
|
<Eye size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => requestDeleteMed(med)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
data-tooltip={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button className="success" onClick={() => reactivateMedication(med.id)}>
|
<button className="success" onClick={() => reactivateMedication(med.id)}>
|
||||||
{t("medications.list.reactivate")}
|
{t("medications.list.reactivate")}
|
||||||
</button>
|
</button>
|
||||||
<button className="info" onClick={() => handleViewClick(med)}>
|
|
||||||
{t("common.view")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="med-details">
|
<div className="med-details">
|
||||||
{med.medicationStartDate && (
|
{med.medicationStartDate && (
|
||||||
@@ -787,35 +931,80 @@ export function MedicationsPage() {
|
|||||||
← {t("common.back")}
|
← {t("common.back")}
|
||||||
</button>
|
</button>
|
||||||
{editingId ? (
|
{editingId ? (
|
||||||
<>
|
|
||||||
<MedicationAvatar
|
|
||||||
name={selectedMedication?.name || ""}
|
|
||||||
imageUrl={selectedMedication?.imageUrl}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
<h2>
|
<h2>
|
||||||
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name}
|
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedication?.name}
|
||||||
</h2>
|
</h2>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<h2>{t("form.newEntry")}</h2>
|
<h2>{t("form.newEntry")}</h2>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="form-grid" onSubmit={saveMedication}>
|
<form
|
||||||
|
className="form-grid"
|
||||||
|
onSubmit={saveMedication}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
>
|
||||||
|
<div className="full form-tabs" role="tablist" aria-label={t("form.sections.general")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "general"}
|
||||||
|
className={`form-tab${activeTab === "general" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("general")}
|
||||||
|
>
|
||||||
|
{t("form.sections.general")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "stock"}
|
||||||
|
className={`form-tab${activeTab === "stock" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("stock")}
|
||||||
|
>
|
||||||
|
{t("form.sections.stock")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "prescription"}
|
||||||
|
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("prescription")}
|
||||||
|
>
|
||||||
|
{t("form.sections.prescription")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "schedule"}
|
||||||
|
className={`form-tab${activeTab === "schedule" ? " active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("schedule")}
|
||||||
|
>
|
||||||
|
{t("form.sections.schedule")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
|
||||||
|
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||||
<label className={!readOnlyView && fieldErrors.name ? "has-error" : ""}>
|
<label className={!readOnlyView && showNameValidation && fieldErrors.name ? "has-error" : ""}>
|
||||||
{t("form.commercialName")}
|
{t("form.commercialName")}
|
||||||
<input
|
<input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setShowNameValidation(true);
|
||||||
|
setForm({ ...form, name: e.target.value });
|
||||||
|
}}
|
||||||
|
onBlur={() => setShowNameValidation(true)}
|
||||||
placeholder={t("form.placeholders.commercial")}
|
placeholder={t("form.placeholders.commercial")}
|
||||||
maxLength={FIELD_LIMITS.name.max}
|
maxLength={FIELD_LIMITS.name.max}
|
||||||
required={!readOnlyView}
|
required={!readOnlyView}
|
||||||
/>
|
/>
|
||||||
{!readOnlyView && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
{!readOnlyView && showNameValidation && fieldErrors.name && (
|
||||||
|
<span className="field-error">{fieldErrors.name}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className={fieldErrors.genericName ? "has-error" : ""}>
|
<label className={fieldErrors.genericName ? "has-error" : ""}>
|
||||||
{t("form.genericName")}
|
{t("form.genericName")}
|
||||||
@@ -833,9 +1022,22 @@ export function MedicationsPage() {
|
|||||||
value={form.medicationStartDate}
|
value={form.medicationStartDate}
|
||||||
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
onChange={(e) => handleValueChange("medicationStartDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
{!readOnlyView && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
{!readOnlyView && dateConsistencyError && (
|
||||||
|
<span className="field-error">{dateConsistencyError}</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className={fieldErrors.takenBy ? "has-error" : ""}>
|
<label>
|
||||||
|
{t("form.packageType")}
|
||||||
|
<select
|
||||||
|
className="package-type-select"
|
||||||
|
value={form.packageType}
|
||||||
|
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||||
|
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||||
{t("form.takenBy")}
|
{t("form.takenBy")}
|
||||||
<div className="tag-input-container">
|
<div className="tag-input-container">
|
||||||
{form.takenBy.map((person) => (
|
{form.takenBy.map((person) => (
|
||||||
@@ -877,19 +1079,78 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
{t("form.packageType")}
|
|
||||||
<select
|
|
||||||
className="package-type-select"
|
|
||||||
value={form.packageType}
|
|
||||||
onChange={(e) => handleValueChange("packageType", e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
|
||||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="full form-category image-section">
|
||||||
|
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
||||||
|
{(() => {
|
||||||
|
if (editingId) {
|
||||||
|
const currentMed = meds.find((m) => m.id === editingId);
|
||||||
|
if (currentMed?.imageUrl) {
|
||||||
|
return (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => deleteMedImage(editingId)}
|
||||||
|
aria-label={t("form.removeImage")}
|
||||||
|
data-tooltip={t("form.removeImage")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
||||||
|
disabled={uploadingImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pendingImagePreview) {
|
||||||
|
return (
|
||||||
|
<div className="image-preview">
|
||||||
|
<img src={pendingImagePreview} alt="Preview" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingImage(null);
|
||||||
|
setPendingImagePreview(null);
|
||||||
|
}}
|
||||||
|
aria-label={t("form.removeImage")}
|
||||||
|
data-tooltip={t("form.removeImage")}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setPendingImage(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* end general tab */}
|
||||||
|
|
||||||
|
<div className={`form-tab-panel${activeTab === "stock" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||||
{form.packageType === "blister" ? (
|
{form.packageType === "blister" ? (
|
||||||
@@ -925,14 +1186,8 @@ export function MedicationsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("form.loosePills")}
|
{t("form.total")}
|
||||||
<input
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={form.looseTablets}
|
|
||||||
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -983,10 +1238,14 @@ export function MedicationsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
{form.packageType === "bottle" && (
|
||||||
|
<div className="full stock-total-row">
|
||||||
|
<label className="stock-total-field">
|
||||||
{t("form.total")}
|
{t("form.total")}
|
||||||
<div className="static-value">{formatNumber(totalTablets)}</div>
|
<div className="static-value">{formatNumber(totalTablets)}</div>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<label>
|
<label>
|
||||||
{t("form.expiryDate")}
|
{t("form.expiryDate")}
|
||||||
<DateInput
|
<DateInput
|
||||||
@@ -1011,14 +1270,19 @@ export function MedicationsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{form.notes.length > 0 && (
|
{form.notes.length > 0 && (
|
||||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
<span
|
||||||
|
className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}
|
||||||
|
>
|
||||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* end stock tab */}
|
||||||
|
|
||||||
|
<div className={`form-tab-panel${activeTab === "prescription" ? " active" : ""}`}>
|
||||||
<div className="full form-category">
|
<div className="full form-category">
|
||||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||||
<label className="full">
|
<label className="full">
|
||||||
@@ -1074,104 +1338,22 @@ export function MedicationsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* end prescription tab */}
|
||||||
|
|
||||||
{!readOnlyView && (
|
<div className={`form-tab-panel${activeTab === "schedule" ? " active" : ""}`}>
|
||||||
<div className="full form-category refill-section">
|
|
||||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
|
||||||
{editingId ? (
|
|
||||||
<>
|
|
||||||
{form.packageType === "blister" ? (
|
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
{t("refill.packs")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillPacks}
|
|
||||||
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("refill.loosePills")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<label className="full">
|
|
||||||
{t("refill.pillsToAdd")}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={refillLoose}
|
|
||||||
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="refill-submit-row full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="success"
|
|
||||||
onClick={() => handleSubmitRefill(editingId)}
|
|
||||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
|
||||||
>
|
|
||||||
{refillSaving ? t("refill.adding") : t("refill.button")}
|
|
||||||
</button>
|
|
||||||
{(() => {
|
|
||||||
const totalRefill =
|
|
||||||
form.packageType === "blister"
|
|
||||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
|
||||||
refillLoose
|
|
||||||
: refillLoose;
|
|
||||||
return totalRefill > 0 ? (
|
|
||||||
<span className="refill-preview">
|
|
||||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{form.prescriptionEnabled && (
|
|
||||||
<div className="refill-prescription-row full">
|
|
||||||
<label className="refill-prescription-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={usePrescriptionRefill}
|
|
||||||
onChange={(e) => setUsePrescriptionRefill(e.target.checked)}
|
|
||||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
|
||||||
/>
|
|
||||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
|
||||||
</label>
|
|
||||||
<span className="refill-remaining-badge">
|
|
||||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="refill-unavailable">
|
|
||||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="full form-category intake-section">
|
<div className="full form-category intake-section">
|
||||||
<div className="form-category-header">
|
<div className="form-category-header">
|
||||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||||
{!readOnlyView && (
|
{!readOnlyView && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="primary"
|
className="primary icon-only tooltip-trigger"
|
||||||
onClick={() => addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
onClick={() => addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||||
|
aria-label={t("form.blisters.addIntake")}
|
||||||
|
data-tooltip={t("form.blisters.addIntake")}
|
||||||
>
|
>
|
||||||
+ {t("form.blisters.addIntake")}
|
<Plus size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1214,7 +1396,7 @@ export function MedicationsPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{form.takenBy.length === 0 ? null : (
|
{form.takenBy.length === 0 ? null : (
|
||||||
<label title={t("form.blisters.takenByTooltip")}>
|
<label className="taken-by-field" title={t("form.blisters.takenByTooltip")}>
|
||||||
{t("form.blisters.takenByIntake")}
|
{t("form.blisters.takenByIntake")}
|
||||||
<select
|
<select
|
||||||
value={intake.takenBy}
|
value={intake.takenBy}
|
||||||
@@ -1229,7 +1411,9 @@ export function MedicationsPage() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||||
<span>🔔</span>
|
<span className="blister-reminder-icon">
|
||||||
|
<Bell size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
<label className="toggle-switch small">
|
<label className="toggle-switch small">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -1241,78 +1425,25 @@ export function MedicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!readOnlyView && form.intakes.length > 1 && (
|
{!readOnlyView && form.intakes.length > 1 && (
|
||||||
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
|
<button
|
||||||
{t("common.remove")}
|
type="button"
|
||||||
|
className="danger icon-only tooltip-trigger"
|
||||||
|
onClick={() => removeIntake(idx)}
|
||||||
|
aria-label={t("common.remove")}
|
||||||
|
data-tooltip={t("common.remove")}
|
||||||
|
>
|
||||||
|
<Minus size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="full form-category image-section">
|
|
||||||
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
|
||||||
{(() => {
|
|
||||||
// When editing an existing medication
|
|
||||||
if (editingId) {
|
|
||||||
const currentMed = meds.find((m) => m.id === editingId);
|
|
||||||
if (currentMed?.imageUrl) {
|
|
||||||
return (
|
|
||||||
<div className="image-preview">
|
|
||||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
|
||||||
<button type="button" className="danger" onClick={() => deleteMedImage(editingId)}>
|
|
||||||
{t("form.removeImage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
onChange={(e) => e.target.files?.[0] && uploadMedImage(editingId, e.target.files[0])}
|
|
||||||
disabled={uploadingImage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// When creating a new medication
|
|
||||||
if (pendingImagePreview) {
|
|
||||||
return (
|
|
||||||
<div className="image-preview">
|
|
||||||
<img src={pendingImagePreview} alt="Preview" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger"
|
|
||||||
onClick={() => {
|
|
||||||
setPendingImage(null);
|
|
||||||
setPendingImagePreview(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("form.removeImage")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
setPendingImage(file);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (ev) => setPendingImagePreview(ev.target?.result as string);
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* end schedule tab */}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="full align-end gap">
|
<div className="full align-end gap">
|
||||||
<button type="button" className="ghost" onClick={handleDesktopFormLeave}>
|
<button type="button" className="ghost" onClick={handleDesktopFormLeave}>
|
||||||
{readOnlyView ? t("common.close") : t("common.cancel")}
|
{readOnlyView || (formSaved && !formChanged) ? t("common.close") : t("common.cancel")}
|
||||||
</button>
|
</button>
|
||||||
{!readOnlyView && (
|
{!readOnlyView && (
|
||||||
<button
|
<button
|
||||||
@@ -1354,14 +1485,6 @@ export function MedicationsPage() {
|
|||||||
onAddIntake={addIntake}
|
onAddIntake={addIntake}
|
||||||
onRemoveIntake={removeIntake}
|
onRemoveIntake={removeIntake}
|
||||||
onHandleValueChange={handleValueChange}
|
onHandleValueChange={handleValueChange}
|
||||||
refillPacks={refillPacks}
|
|
||||||
onRefillPacksChange={setRefillPacks}
|
|
||||||
refillLoose={refillLoose}
|
|
||||||
onRefillLooseChange={setRefillLoose}
|
|
||||||
usePrescriptionRefill={usePrescriptionRefill}
|
|
||||||
onUsePrescriptionRefillChange={setUsePrescriptionRefill}
|
|
||||||
refillSaving={refillSaving}
|
|
||||||
onSubmitRefill={handleSubmitRefill}
|
|
||||||
meds={allMeds}
|
meds={allMeds}
|
||||||
onUploadMedImage={uploadMedImage}
|
onUploadMedImage={uploadMedImage}
|
||||||
onDeleteMedImage={deleteMedImage}
|
onDeleteMedImage={deleteMedImage}
|
||||||
@@ -1397,7 +1520,7 @@ export function MedicationsPage() {
|
|||||||
cancelLabel={t("common.cancel")}
|
cancelLabel={t("common.cancel")}
|
||||||
onConfirm={handleConfirmMarkObsolete}
|
onConfirm={handleConfirmMarkObsolete}
|
||||||
onCancel={handleCancelMarkObsolete}
|
onCancel={handleCancelMarkObsolete}
|
||||||
confirmVariant="danger"
|
confirmVariant="warning"
|
||||||
overlayClassName={showEditModal ? "nested-confirm" : undefined}
|
overlayClassName={showEditModal ? "nested-confirm" : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1420,6 +1543,9 @@ export function MedicationsPage() {
|
|||||||
{lightboxImage && (
|
{lightboxImage && (
|
||||||
<Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={() => setLightboxImage(null)} />
|
<Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={() => setLightboxImage(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Report Modal */}
|
||||||
|
<ReportModal isOpen={showReportModal} onClose={() => setShowReportModal(false)} medications={allMeds} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||||
@@ -168,6 +169,7 @@ export function PlannerPage() {
|
|||||||
{t("planner.until")}
|
{t("planner.until")}
|
||||||
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
|
<div className="planner-checkbox-row">
|
||||||
<label className="planner-checkbox">
|
<label className="planner-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -175,10 +177,11 @@ export function PlannerPage() {
|
|||||||
onChange={(e) => setIncludeUntilStart(e.target.checked)}
|
onChange={(e) => setIncludeUntilStart(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t("planner.includeUntilStart")}
|
{t("planner.includeUntilStart")}
|
||||||
|
</label>
|
||||||
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
|
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
|
||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</div>
|
||||||
<div className="planner-actions">
|
<div className="planner-actions">
|
||||||
<button type="button" className="ghost" onClick={resetRange}>
|
<button type="button" className="ghost" onClick={resetRange}>
|
||||||
{t("common.reset")}
|
{t("common.reset")}
|
||||||
@@ -204,15 +207,26 @@ export function PlannerPage() {
|
|||||||
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
|
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
|
||||||
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
||||||
return (
|
return (
|
||||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
<div
|
||||||
|
key={row.medicationId}
|
||||||
|
className="table-row clickable"
|
||||||
|
onClick={() => med && openMedDetail(med)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (med) openMedDetail(med);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
|
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
|
||||||
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
|
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
|
||||||
{row.medicationName}
|
{row.medicationName}
|
||||||
</span>
|
</span>
|
||||||
<span data-label={t("planner.table.usage")}>
|
<span data-label={t("planner.table.usage")}>
|
||||||
|
<span>
|
||||||
<strong>{row.plannerUsage}</strong>
|
<strong>{row.plannerUsage}</strong>
|
||||||
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
<span data-label={t("planner.table.blisters")}>
|
<span data-label={t("planner.table.blisters")}>
|
||||||
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
@@ -65,6 +67,7 @@ export function SchedulePage() {
|
|||||||
pastDays,
|
pastDays,
|
||||||
futureDays,
|
futureDays,
|
||||||
takenDoses,
|
takenDoses,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
@@ -129,7 +132,7 @@ export function SchedulePage() {
|
|||||||
|
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -139,6 +142,9 @@ export function SchedulePage() {
|
|||||||
<div
|
<div
|
||||||
className="day-divider clickable"
|
className="day-divider clickable"
|
||||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||||||
|
}}
|
||||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||||
>
|
>
|
||||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||||
@@ -189,27 +195,36 @@ export function SchedulePage() {
|
|||||||
<div key={dose.id} className="dose-item past">
|
<div key={dose.id} className="dose-item past">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
</span>
|
||||||
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
onClick={() => openUserFilter(person)}
|
onClick={() => openUserFilter(person)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
@@ -220,6 +235,14 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -229,7 +252,8 @@ export function SchedulePage() {
|
|||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +288,19 @@ export function SchedulePage() {
|
|||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
const wasCollapsed = !showPastDays;
|
||||||
|
setShowPastDays(!showPastDays);
|
||||||
|
if (wasCollapsed) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document
|
||||||
|
.querySelector(".day-block.today")
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||||
<span className="past-days-label">
|
<span className="past-days-label">
|
||||||
@@ -329,21 +366,27 @@ export function SchedulePage() {
|
|||||||
<div key={dose.id} className="dose-item">
|
<div key={dose.id} className="dose-item">
|
||||||
<span className="dose-time">{dose.timeStr}</span>
|
<span className="dose-time">{dose.timeStr}</span>
|
||||||
<span className="dose-usage">
|
<span className="dose-usage">
|
||||||
|
<span className="dose-usage-main">
|
||||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
</span>
|
||||||
|
{med?.pillWeightMg && (
|
||||||
|
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{dose.intakeRemindersEnabled && (
|
{dose.intakeRemindersEnabled && (
|
||||||
<span
|
<span
|
||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -351,7 +394,13 @@ export function SchedulePage() {
|
|||||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||||
>
|
>
|
||||||
{person && (
|
{person && (
|
||||||
<span className="person-name clickable" onClick={() => openUserFilter(person)}>
|
<span
|
||||||
|
className="person-name clickable"
|
||||||
|
onClick={() => openUserFilter(person)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -361,6 +410,11 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -370,7 +424,8 @@ export function SchedulePage() {
|
|||||||
disabled={isEmpty}
|
disabled={isEmpty}
|
||||||
title={t("dose.markAsTaken")}
|
title={t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
✓
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, ExportModal } from "../components";
|
import { ConfirmModal, ExportModal } from "../components";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
@@ -30,8 +31,10 @@ export function SettingsPage() {
|
|||||||
handleImportConfirm,
|
handleImportConfirm,
|
||||||
importResult,
|
importResult,
|
||||||
setImportResult,
|
setImportResult,
|
||||||
|
meds,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
|
const hasExistingData = meds.length > 0;
|
||||||
return (
|
return (
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
{settingsLoading ? (
|
{settingsLoading ? (
|
||||||
@@ -43,7 +46,6 @@ export function SettingsPage() {
|
|||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("settings.language.title")}</h2>
|
<h2>{t("settings.language.title")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-section">
|
|
||||||
<label className="setting-row language-row">
|
<label className="setting-row language-row">
|
||||||
<span className="setting-label">{t("settings.language.select")}</span>
|
<span className="setting-label">{t("settings.language.select")}</span>
|
||||||
<select
|
<select
|
||||||
@@ -64,7 +66,6 @@ export function SettingsPage() {
|
|||||||
<option value="de">🇩🇪 Deutsch</option>
|
<option value="de">🇩🇪 Deutsch</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
@@ -89,7 +90,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
checked={settings.emailStockReminders}
|
||||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -100,9 +101,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrStockReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +115,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
checked={settings.emailIntakeReminders}
|
||||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -127,9 +126,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrIntakeReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -143,9 +140,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.emailPrescriptionReminders}
|
||||||
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -156,11 +151,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrPrescriptionReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled
|
|
||||||
? settings.shoutrrrPrescriptionReminders
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -373,9 +364,16 @@ export function SettingsPage() {
|
|||||||
{settings.emailEnabled && (
|
{settings.emailEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="full">
|
<div className="full">
|
||||||
<span className="field-label">{t("settings.email.recipient")}</span>
|
<span className="field-label">
|
||||||
<div className="input-with-tooltip">
|
{t("settings.email.recipient")}
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={settings.notificationEmail}
|
value={settings.notificationEmail}
|
||||||
@@ -384,14 +382,7 @@ export function SettingsPage() {
|
|||||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
|
|
||||||
>
|
|
||||||
ⓘ
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
<button
|
<button
|
||||||
@@ -442,23 +433,23 @@ export function SettingsPage() {
|
|||||||
{settings.shoutrrrEnabled && (
|
{settings.shoutrrrEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="full">
|
<div className="full">
|
||||||
<span className="field-label">{t("settings.push.url")}</span>
|
<span className="field-label">
|
||||||
<div className="input-with-tooltip">
|
{t("settings.push.url")}
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.shoutrrrUrl}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
|
||||||
placeholder={t("settings.push.urlPlaceholder")}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
className="info-tooltip"
|
className="info-tooltip"
|
||||||
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
|
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
|
||||||
>
|
>
|
||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.shoutrrrUrl}
|
||||||
|
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||||
|
placeholder={t("settings.push.urlPlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-actions">
|
<div className="setting-actions">
|
||||||
<button
|
<button
|
||||||
@@ -606,7 +597,7 @@ export function SettingsPage() {
|
|||||||
<h3>{t("settings.stock.thresholds")}</h3>
|
<h3>{t("settings.stock.thresholds")}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="setting-group threshold-chips-group">
|
<div className="setting-group threshold-chips-group">
|
||||||
<label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
|
<div className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||||
<span className="field-label threshold-chip-label">
|
<span className="field-label threshold-chip-label">
|
||||||
<span className="status-chip small danger">{t("status.criticalStock")}</span>
|
<span className="status-chip small danger">{t("status.criticalStock")}</span>
|
||||||
<span
|
<span
|
||||||
@@ -616,7 +607,6 @@ export function SettingsPage() {
|
|||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="input-with-tooltip">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -625,8 +615,7 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<div
|
||||||
<label
|
|
||||||
className={
|
className={
|
||||||
settings.lowStockDays <= settings.reminderDaysBefore ||
|
settings.lowStockDays <= settings.reminderDaysBefore ||
|
||||||
settings.lowStockDays >= settings.highStockDays
|
settings.lowStockDays >= settings.highStockDays
|
||||||
@@ -643,7 +632,6 @@ export function SettingsPage() {
|
|||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="input-with-tooltip">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -652,8 +640,7 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<div className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||||
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
|
|
||||||
<span className="field-label threshold-chip-label">
|
<span className="field-label threshold-chip-label">
|
||||||
<span className="status-chip small high">{t("status.highStock")}</span>
|
<span className="status-chip small high">{t("status.highStock")}</span>
|
||||||
<span
|
<span
|
||||||
@@ -663,7 +650,6 @@ export function SettingsPage() {
|
|||||||
ⓘ
|
ⓘ
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="input-with-tooltip">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -672,15 +658,68 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{(settings.reminderDaysBefore >= settings.lowStockDays ||
|
{(settings.reminderDaysBefore >= settings.lowStockDays ||
|
||||||
settings.lowStockDays >= settings.highStockDays) && (
|
settings.lowStockDays >= settings.highStockDays) && (
|
||||||
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* General UI */}
|
||||||
|
<article className="card">
|
||||||
|
<div className="card-head">
|
||||||
|
<h2>{t("settings.timeline.title")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>{t("settings.timeline.dashboardSectionOrder")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="setting-row compact">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>{t("settings.timeline.swapDashboardSections")}</span>
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("settings.timeline.swapDashboardSectionsDesc")}>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.swapDashboardMainSections}
|
||||||
|
onChange={(e) => setSettings({ ...settings, swapDashboardMainSections: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>{t("settings.timeline.upcomingSection")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="setting-row compact">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>{t("settings.timeline.upcomingTodayOnly")}</span>
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("settings.timeline.upcomingTodayOnlyDesc")}>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.upcomingTodayOnly}
|
||||||
|
onChange={(e) => setSettings({ ...settings, upcomingTodayOnly: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>{t("settings.timeline.sharedSection")}</h3>
|
||||||
|
</div>
|
||||||
<div className="setting-row compact">
|
<div className="setting-row compact">
|
||||||
<div className="setting-label">
|
<div className="setting-label">
|
||||||
<span>{t("settings.stock.shareStockStatus")}</span>
|
<span>{t("settings.stock.shareStockStatus")}</span>
|
||||||
@@ -697,6 +736,22 @@ export function SettingsPage() {
|
|||||||
<span className="toggle-slider"></span>
|
<span className="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="setting-row compact" style={{ marginTop: "10px" }}>
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>{t("settings.timeline.shareScheduleTodayOnly")}</span>
|
||||||
|
<span className="info-tooltip small" data-tooltip={t("settings.timeline.shareScheduleTodayOnlyDesc")}>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch small">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.shareScheduleTodayOnly}
|
||||||
|
onChange={(e) => setSettings({ ...settings, shareScheduleTodayOnly: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -734,6 +789,7 @@ export function SettingsPage() {
|
|||||||
{t("exportImport.importSuccessDetails", {
|
{t("exportImport.importSuccessDetails", {
|
||||||
medications: importResult.medications,
|
medications: importResult.medications,
|
||||||
doses: importResult.doses,
|
doses: importResult.doses,
|
||||||
|
refills: importResult.refills,
|
||||||
shares: importResult.shares,
|
shares: importResult.shares,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
@@ -741,6 +797,7 @@ export function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setImportResult(null)}
|
onClick={() => setImportResult(null)}
|
||||||
|
aria-label={t("common.close")}
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
@@ -751,7 +808,6 @@ export function SettingsPage() {
|
|||||||
color: "inherit",
|
color: "inherit",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
}}
|
}}
|
||||||
aria-label="Close"
|
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -806,21 +862,25 @@ export function SettingsPage() {
|
|||||||
{/* Import Confirmation Modal */}
|
{/* Import Confirmation Modal */}
|
||||||
{showImportConfirm && (
|
{showImportConfirm && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={t("exportImport.confirmImport")}
|
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
|
||||||
message={
|
message={
|
||||||
|
hasExistingData ? (
|
||||||
<>
|
<>
|
||||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
confirmLabel={t("exportImport.confirmButton")}
|
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
|
||||||
cancelLabel={t("exportImport.cancelButton")}
|
cancelLabel={t("exportImport.cancelButton")}
|
||||||
onConfirm={handleImportConfirm}
|
onConfirm={handleImportConfirm}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowImportConfirm(false);
|
setShowImportConfirm(false);
|
||||||
setPendingImportData(null);
|
setPendingImportData(null);
|
||||||
}}
|
}}
|
||||||
confirmVariant="danger"
|
confirmVariant={hasExistingData ? "danger" : "primary"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Coverage } from "../types";
|
||||||
|
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||||
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
|
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
|
return userId ? `user_${userId}_${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlisterStock(
|
||||||
|
totalPills: number,
|
||||||
|
pillsPerBlister: number,
|
||||||
|
looseTablets: number,
|
||||||
|
_originalTotal: number
|
||||||
|
) {
|
||||||
|
return splitCurrentBlisterStock(totalPills, pillsPerBlister, looseTablets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||||
|
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOpenBlisterAndLoose(
|
||||||
|
openBlisterPills: number,
|
||||||
|
loosePills: number,
|
||||||
|
pillsPerBlister: number,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string {
|
||||||
|
if (openBlisterPills > 0 && loosePills > 0) {
|
||||||
|
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")} + ${loosePills} ${t("modal.loosePills")}`;
|
||||||
|
}
|
||||||
|
if (openBlisterPills > 0) {
|
||||||
|
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||||
|
}
|
||||||
|
if (loosePills > 0) {
|
||||||
|
return `${loosePills} ${t("modal.loosePills")}`;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMedTotal(med: {
|
||||||
|
packCount: number;
|
||||||
|
blistersPerPack: number;
|
||||||
|
pillsPerBlister: number;
|
||||||
|
looseTablets: number;
|
||||||
|
stockAdjustment?: number | null;
|
||||||
|
packageType?: string;
|
||||||
|
}): number {
|
||||||
|
return getMedTotalFromTypes(med);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReminderStatusData(
|
||||||
|
reminderDaysBefore: number,
|
||||||
|
lowStockDays: number,
|
||||||
|
_allLowCoverage: Coverage[],
|
||||||
|
allCoverage: Coverage[],
|
||||||
|
lastAutoEmailSent: string | null,
|
||||||
|
_lastNotificationType: string | null,
|
||||||
|
_lastNotificationChannel: string | null,
|
||||||
|
lastReminderMedName: string | null,
|
||||||
|
lastReminderTakenBy: string | null,
|
||||||
|
lastStockReminderSent: string | null,
|
||||||
|
_lastStockReminderChannel: string | null,
|
||||||
|
lastStockReminderMedNames: string | null,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
|
locale: string
|
||||||
|
): {
|
||||||
|
status: { text: string; className: string };
|
||||||
|
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||||
|
lastStockSent: { date: string; medNames: string | null } | null;
|
||||||
|
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||||
|
} {
|
||||||
|
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||||
|
|
||||||
|
for (const c of allCoverage) {
|
||||||
|
if (c.medsLeft <= 0) {
|
||||||
|
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.daysLeft === null) continue;
|
||||||
|
|
||||||
|
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||||
|
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||||
|
const isLow = c.daysLeft < lowStockDays;
|
||||||
|
if (!isCritical && !isLow) continue;
|
||||||
|
|
||||||
|
const existing = lowStockMap.get(c.name);
|
||||||
|
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||||
|
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||||
|
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||||
|
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||||
|
|
||||||
|
let status: { text: string; className: string };
|
||||||
|
if (criticalCount > 0) {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||||
|
className: "danger",
|
||||||
|
};
|
||||||
|
} else if (lowCount > 0) {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||||
|
className: "warning",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.allOk"),
|
||||||
|
className: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||||
|
if (lastStockReminderSent) {
|
||||||
|
const sentDate = new Date(lastStockReminderSent);
|
||||||
|
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
lastStockSent = {
|
||||||
|
date: formattedDate,
|
||||||
|
medNames: lastStockReminderMedNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||||
|
if (lastAutoEmailSent) {
|
||||||
|
const sentDate = new Date(lastAutoEmailSent);
|
||||||
|
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
lastIntakeSent = {
|
||||||
|
date: formattedDate,
|
||||||
|
medName: lastReminderMedName,
|
||||||
|
takenBy: lastReminderTakenBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||||
|
}
|
||||||
+976
-1052
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user