Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4b697374f6 |
@@ -89,6 +89,29 @@ PR #141: "fix: planner checkbox layout on single line"
|
||||
|
||||
---
|
||||
|
||||
## PR Metadata (MANDATORY)
|
||||
|
||||
Every Pull Request MUST have the following sidebar fields populated at creation time:
|
||||
|
||||
| Field | Value | How |
|
||||
|-------|-------|-----|
|
||||
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
|
||||
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
|
||||
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
|
||||
|
||||
**Label mapping for PRs:**
|
||||
| Branch prefix / commit type | Label |
|
||||
|---|---|
|
||||
| `feat/` | `enhancement` |
|
||||
| `fix/` | `bug` |
|
||||
| `docs/` | `documentation` |
|
||||
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
|
||||
| `chore/release-*` | No label needed (release PRs are automated) |
|
||||
|
||||
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Branch, PR, and Merge Workflow
|
||||
|
||||
When code changes (features or bug fixes) are complete:
|
||||
@@ -121,13 +144,20 @@ When code changes (features or bug fixes) are complete:
|
||||
```bash
|
||||
git push -u origin feat/short-description
|
||||
```
|
||||
2. Create a Pull Request via GitHub CLI, linking the related issue:
|
||||
2. Create a Pull Request via GitHub CLI with **all metadata fields populated**:
|
||||
```bash
|
||||
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
|
||||
gh pr create \
|
||||
--title "fix: short description" \
|
||||
--body "Closes #<ISSUE_NUMBER>
|
||||
|
||||
Description of changes"
|
||||
Description of changes" \
|
||||
--assignee DanielVolz \
|
||||
--label bug \
|
||||
--project "@DanielVolz's MedAssist-ng project"
|
||||
```
|
||||
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
|
||||
- Use `--label enhancement` for `feat/` branches, `--label bug` for `fix/` branches, `--label documentation` for `docs/` branches.
|
||||
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
|
||||
- The `--project` flag links the PR to the Project board.
|
||||
3. **Present the PR URL to the user and wait for confirmation.**
|
||||
|
||||
### Step 4: Wait for CI and Merge
|
||||
@@ -462,7 +492,7 @@ Code complete & validated by testing-manager
|
||||
↓
|
||||
1. Ensure a GitHub issue exists (create if not)
|
||||
2. Create feature branch (fix/... or feat/...)
|
||||
3. Commit, push, create PR (with "Closes #N" in body)
|
||||
3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
|
||||
4. Wait for CI (all required checks)
|
||||
5. Merge PR to main (squash + delete branch)
|
||||
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
|
||||
|
||||
@@ -15,6 +15,7 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
|
||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
|
||||
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
|
||||
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
|
||||
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
|
||||
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
|
||||
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
|
||||
|
||||
@@ -67,8 +68,8 @@ cd frontend && npm run build
|
||||
```bash
|
||||
cd frontend && npm run test:e2e
|
||||
cd frontend && npm run test:e2e -- --project=chromium
|
||||
cd frontend && npm run test:e2e:ui
|
||||
cd frontend && npm run test:e2e:headed
|
||||
# Never use interactive UI/headed/report-server commands in agent runs.
|
||||
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
|
||||
```
|
||||
|
||||
## Backend Test Patterns
|
||||
|
||||
@@ -28,6 +28,7 @@ Use this orientation for quick navigation before applying the rules below.
|
||||
- Testing work belongs to `@testing-manager`.
|
||||
- PR/release/CI orchestration belongs to `@release-manager`.
|
||||
- Keep changes local, focused, and consistent with existing UI/API patterns.
|
||||
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
|
||||
- Remove obsolete code when re-implementing — never leave dead code behind.
|
||||
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||
steps:
|
||||
- name: Move project item to Done
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
script: |
|
||||
|
||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"vitest.root": "backend",
|
||||
"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 align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -123,6 +123,7 @@ Share your medication schedule with others via a public link.
|
||||
- Track exact stock: packs, blisters, bottles, and loose pills
|
||||
- Display remaining days of supply
|
||||
- Automatic calculation based on intake schedule
|
||||
- Manual stock correction supports partial blisters and loose pills
|
||||
|
||||
### Medication Refill
|
||||
- One-click refill with pack or loose pill options
|
||||
@@ -132,6 +133,7 @@ Share your medication schedule with others via a public link.
|
||||
### Flexible Schedules
|
||||
- Daily, weekly, or custom intervals per medication
|
||||
- Independent schedules for each medication
|
||||
- Optional timeline filters for dashboard and shared schedule views
|
||||
|
||||
### Stock Alerts & Reminders
|
||||
- Notifications before stock runs out
|
||||
@@ -143,6 +145,10 @@ Share your medication schedule with others via a public link.
|
||||
- Plan ahead for vacations, business trips, or hospital stays
|
||||
- Send demand reports via email or push notification
|
||||
|
||||
### Reports
|
||||
- Generate medication reports as PDF, Markdown, or plain text
|
||||
- Include intake history, refill history, and prescription details
|
||||
|
||||
### Multi-Person Support
|
||||
- Manage medications for multiple people
|
||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
|
||||
ALTER TABLE `medications` ADD `obsolete_at` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
|
||||
@@ -57,6 +57,20 @@
|
||||
"when": 1770659669121,
|
||||
"tag": "0007_add_share_stock_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1771160400000,
|
||||
"tag": "0008_add_obsolete_medications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+49
-1419
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.11.1",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -35,9 +35,9 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.15",
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/nodemailer": "^6.4.21",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
@@ -8,7 +7,6 @@ import { log } from "../utils/logger.js";
|
||||
import {
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDataDir,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
@@ -65,8 +63,8 @@ let client: Client;
|
||||
try {
|
||||
client = createClient({ url });
|
||||
log.debug(`[DB] Database client created successfully`);
|
||||
} catch (err: any) {
|
||||
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
|
||||
} catch (err: unknown) {
|
||||
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
|
||||
log.error(`[DB] Database path: ${dbPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+32
-23
@@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error?
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,14 +87,14 @@ export async function runDrizzleMigrations(
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// If the error is about existing schema objects, the DB is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns,
|
||||
// or when tables were created before drizzle migrations were introduced
|
||||
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for soft-archiving medications (without deleting history)
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
// Added for explicit medication lifecycle start date
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
@@ -135,6 +140,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||
// Added for share stock visibility toggle
|
||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||
// Added for timeline visibility toggles (dashboard + shared schedule)
|
||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
|
||||
// Added for prescription refill tracking and reminders
|
||||
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
|
||||
@@ -153,10 +162,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
for (const sql of alterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
if (!(e as Error).message?.includes("duplicate column")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,10 +186,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,10 +203,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
for (const sql of createIndexMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
// Silently ignore "already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
if (!(e as Error).message?.includes("already exists")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +231,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
} catch (e: unknown) {
|
||||
console.error(`[DB] Error creating default user:`, (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -250,8 +259,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep
|
||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||
);
|
||||
repaired = result.rowsAffected;
|
||||
} catch (e: any) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
@@ -374,14 +383,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
args: [newDoseId, dose.id],
|
||||
});
|
||||
repaired++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Repair failed: ${e.message}`);
|
||||
} catch (e: unknown) {
|
||||
errors.push(`Repair failed: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
|
||||
@@ -41,8 +41,8 @@ export async function executeMigration(
|
||||
const executed = Number(tables.rows[0].count) || 0;
|
||||
|
||||
return { success: true, executed, errors };
|
||||
} catch (err: any) {
|
||||
errors.push(err.message);
|
||||
} catch (err: unknown) {
|
||||
errors.push((err as Error).message);
|
||||
return { success: false, executed: 0, errors };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,21 @@ export function getTableCreationSQL(): string[] {
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
last_reminder_med_name text,
|
||||
last_reminder_taken_by text,
|
||||
last_stock_reminder_sent text,
|
||||
last_stock_reminder_channel text,
|
||||
last_stock_reminder_med_names text,
|
||||
last_prescription_reminder_sent text,
|
||||
last_prescription_reminder_channel text,
|
||||
last_prescription_reminder_med_names text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
|
||||
@@ -47,6 +47,9 @@ export const medications = sqliteTable("medications", {
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
|
||||
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
|
||||
@@ -97,6 +100,10 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||
// UI timeline visibility preferences
|
||||
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
|
||||
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
|
||||
// Last notification tracking (intake reminders)
|
||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||
lastNotificationType: text("last_notification_type"),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { medicationRoutes } from "./routes/medications.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
import { reportRoutes } from "./routes/report.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { shareRoutes } from "./routes/share.js";
|
||||
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
|
||||
@@ -118,6 +119,7 @@ export async function createApp(options?: {
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -190,6 +192,7 @@ await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
|
||||
@@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// Re-throw our own errors
|
||||
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// JWT verification failed
|
||||
|
||||
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { extname, resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.0";
|
||||
const EXPORT_VERSION = "1.1";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -35,6 +35,7 @@ const inventorySchema = z.object({
|
||||
packCount: z.number().int().min(0).default(1),
|
||||
blistersPerPack: z.number().int().min(1).default(1),
|
||||
pillsPerBlister: z.number().int().min(1).default(1),
|
||||
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||
packageType: z.enum(["blister", "bottle"]).default("blister"),
|
||||
@@ -49,14 +50,18 @@ const medicationExportSchema = z.object({
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
isObsolete: z.boolean().default(false),
|
||||
obsoleteAt: z.string().nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
|
||||
prescriptionExpiryDate: z.string().nullable().optional(),
|
||||
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
|
||||
image: z.string().nullable().optional(), // base64 data URL or null
|
||||
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||
});
|
||||
@@ -71,6 +76,14 @@ const doseHistorySchema = z.object({
|
||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||
});
|
||||
|
||||
const refillHistoryExportSchema = z.object({
|
||||
medicationRef: z.string(), // References _exportId
|
||||
packsAdded: z.number().int().min(0).default(0),
|
||||
loosePillsAdded: z.number().int().min(0).default(0),
|
||||
usedPrescription: z.boolean().default(false),
|
||||
refillDate: z.string(), // ISO datetime
|
||||
});
|
||||
|
||||
const shareLinkSchema = z.object({
|
||||
takenBy: z.string().min(1),
|
||||
scheduleDays: z.number().int().min(1).default(30),
|
||||
@@ -103,9 +116,11 @@ const settingsExportSchema = z
|
||||
lowStockDays: z.number().int().default(30),
|
||||
normalStockDays: z.number().int().default(90),
|
||||
highStockDays: z.number().int().default(180),
|
||||
expiryWarningDays: z.number().int().default(90),
|
||||
// UI preferences
|
||||
language: z.string().default("en"),
|
||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||
shareStockStatus: z.boolean().default(true),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -115,6 +130,7 @@ const importDataSchema = z.object({
|
||||
includeSensitiveData: z.boolean().default(false),
|
||||
medications: z.array(medicationExportSchema).default([]),
|
||||
doseHistory: z.array(doseHistorySchema).default([]),
|
||||
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||
settings: settingsExportSchema,
|
||||
shareLinks: z.array(shareLinkSchema).default([]),
|
||||
});
|
||||
@@ -124,7 +140,7 @@ const importDataSchema = z.object({
|
||||
// =============================================================================
|
||||
|
||||
// Helper to get user ID from request
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
}
|
||||
@@ -282,6 +298,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
packCount: med.packCount ?? 1,
|
||||
blistersPerPack: med.blistersPerPack ?? 1,
|
||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||
totalPills: med.totalPills ?? null,
|
||||
looseTablets: med.looseTablets ?? 0,
|
||||
stockAdjustment: med.stockAdjustment ?? 0,
|
||||
packageType: med.packageType ?? "blister",
|
||||
@@ -289,14 +306,18 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
|
||||
dismissedUntil: med.dismissedUntil ?? null,
|
||||
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||
};
|
||||
@@ -374,8 +395,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lowStockDays: settings.lowStockDays,
|
||||
normalStockDays: settings.normalStockDays,
|
||||
highStockDays: settings.highStockDays,
|
||||
expiryWarningDays: settings.expiryWarningDays,
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
shareStockStatus: settings.shareStockStatus,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -406,6 +429,39 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
// 5. Load refill history
|
||||
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
|
||||
const exportRefillHistory = refills
|
||||
.map((refill) => {
|
||||
const exportId = medIdToExportId.get(refill.medicationId);
|
||||
if (!exportId) return null; // Orphaned refill, skip
|
||||
|
||||
// Safely convert refillDate to ISO string
|
||||
let refillDateIso: string;
|
||||
try {
|
||||
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
|
||||
refillDateIso = refill.refillDate.toISOString();
|
||||
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
|
||||
const d = new Date(refill.refillDate);
|
||||
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||
} else {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
} catch {
|
||||
refillDateIso = new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
medicationRef: exportId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: refillDateIso,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
// Build export object
|
||||
const exportData = {
|
||||
version: EXPORT_VERSION,
|
||||
@@ -413,12 +469,17 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
includeSensitiveData: includeSensitive,
|
||||
medications: exportMedications,
|
||||
doseHistory: exportDoseHistory,
|
||||
refillHistory: exportRefillHistory,
|
||||
settings: exportSettings,
|
||||
shareLinks: exportShareLinks,
|
||||
};
|
||||
|
||||
// Set download headers
|
||||
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
|
||||
const userPart = authUser?.username ? `-${authUser.username}` : "";
|
||||
const filename = `medassist-export${userPart}-${dateStr}.json`;
|
||||
reply.header("Content-Type", "application/json");
|
||||
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
|
||||
@@ -469,7 +530,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in order: doses, share tokens, medications, settings
|
||||
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await db.delete(medications).where(eq(medications.userId, userId));
|
||||
@@ -511,10 +573,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -522,11 +586,14 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null, // Will be set after image is saved
|
||||
})
|
||||
.returning();
|
||||
@@ -585,8 +652,10 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -604,11 +673,27 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Import refill history with remapped medication IDs
|
||||
for (const refill of importData.refillHistory) {
|
||||
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned refill records
|
||||
|
||||
await db.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imported: {
|
||||
medications: importData.medications.length,
|
||||
doseHistory: importData.doseHistory.length,
|
||||
refillHistory: importData.refillHistory.length,
|
||||
settings: importData.settings ? 1 : 0,
|
||||
shareLinks: importData.shareLinks.length,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -32,6 +32,9 @@ const blisterSchema = z.object({
|
||||
|
||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
@@ -46,6 +49,7 @@ const medicationSchema = z
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||
doseUnit: doseUnitSchema,
|
||||
medicationStartDate: medicationStartDateSchema,
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
@@ -59,6 +63,19 @@ const medicationSchema = z
|
||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDate = data.medicationStartDate ?? "";
|
||||
if (!startDate) return true;
|
||||
|
||||
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
|
||||
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
|
||||
},
|
||||
{
|
||||
message: "Medication start date must be on or before all intake dates",
|
||||
path: ["medicationStartDate"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
@@ -103,9 +120,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const includeObsolete = request.query.includeObsolete === "true";
|
||||
const whereClause = includeObsolete
|
||||
? eq(medications.userId, userId)
|
||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||
return rows.map((row) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -129,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
doseUnit: row.doseUnit ?? "mg",
|
||||
medicationStartDate: row.medicationStartDate || null,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
// Legacy blisters format (for backward compat with frontend during transition)
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
@@ -136,6 +158,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
expiryDate: row.expiryDate,
|
||||
notes: row.notes,
|
||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
||||
isObsolete: row.isObsolete ?? false,
|
||||
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: row.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
|
||||
@@ -164,6 +188,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -222,6 +247,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -252,12 +278,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
doseUnit: inserted.doseUnit ?? "mg",
|
||||
medicationStartDate: inserted.medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: inserted.imageUrl,
|
||||
expiryDate: inserted.expiryDate,
|
||||
notes: inserted.notes,
|
||||
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
||||
isObsolete: inserted.isObsolete ?? false,
|
||||
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
|
||||
@@ -294,6 +323,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -362,6 +392,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -516,12 +547,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
doseUnit: result[0].doseUnit ?? "mg",
|
||||
medicationStartDate: result[0].medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: result[0].imageUrl,
|
||||
expiryDate: result[0].expiryDate,
|
||||
notes: result[0].notes,
|
||||
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
||||
isObsolete: result[0].isObsolete ?? false,
|
||||
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
|
||||
@@ -531,9 +565,67 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: true,
|
||||
obsoleteAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - updates stockAdjustment and optionally looseTablets (for blister type)
|
||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number; looseTablets?: number } }>(
|
||||
"/medications/:id/stock-adjustment",
|
||||
async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
@@ -548,16 +640,32 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
||||
const { stockAdjustment, looseTablets } = req.body as { stockAdjustment: number; looseTablets?: number };
|
||||
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||
if (
|
||||
looseTablets !== undefined &&
|
||||
(typeof looseTablets !== "number" || !Number.isInteger(looseTablets) || looseTablets < 0)
|
||||
) {
|
||||
return reply.badRequest("looseTablets must be a non-negative integer");
|
||||
}
|
||||
|
||||
const updateFields: {
|
||||
stockAdjustment: number;
|
||||
lastStockCorrectionAt: Date;
|
||||
updatedAt: Date;
|
||||
looseTablets?: number;
|
||||
} = {
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (looseTablets !== undefined) {
|
||||
updateFields.looseTablets = looseTablets;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.set(updateFields)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
@@ -678,7 +786,17 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const [settingsRow] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
@@ -686,20 +804,25 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
// Create a map of medication ID to taken dose count
|
||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
takenDoses.forEach((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
||||
if (!takenDosesMap.has(medId)) {
|
||||
takenDosesMap.set(medId, []);
|
||||
}
|
||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
||||
}
|
||||
if (parts.length < 3) return;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) return;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||
? rawTakenAt < 1_000_000_000_000
|
||||
? rawTakenAt * 1000
|
||||
: rawTakenAt
|
||||
: new Date(dose.takenAt).getTime();
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
});
|
||||
|
||||
// Use current time as the reference point for "available" stock
|
||||
@@ -726,69 +849,106 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
? looseTablets + stockAdjustment
|
||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||
|
||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
||||
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
|
||||
// Build a Set of taken dose IDs for quick lookup
|
||||
const takenDoseIds = new Set(
|
||||
takenDoses
|
||||
.filter((dose) => {
|
||||
const parts = dose.doseId.split("-");
|
||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
||||
})
|
||||
.map((dose) => dose.doseId)
|
||||
);
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
// After a stock correction, start counting from the NEXT scheduled
|
||||
// dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart.getTime();
|
||||
}
|
||||
if (effectiveStart > now.getTime()) return;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
const peopleForThisIntake = intakePerson
|
||||
? [intakePerson]
|
||||
: fallbackPeople.length > 0
|
||||
? fallbackPeople
|
||||
: [null];
|
||||
|
||||
// Get the people for this intake (from intakes array or medication takenBy)
|
||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const peopleForThisIntake: (string | null)[] = intakePerson
|
||||
? [intakePerson]
|
||||
: takenByJson.length > 0
|
||||
? takenByJson
|
||||
: [null];
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
// Generate expected dose IDs and check if they're taken
|
||||
for (let i = 0; i < occurrences; i++) {
|
||||
const doseDate = new Date(effectiveStart + i * period);
|
||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
// Check if each person has taken this dose
|
||||
for (const person of peopleForThisIntake) {
|
||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||
if (takenDoseIds.has(doseId)) {
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumedUntilNow += blister.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||
|
||||
@@ -834,6 +994,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
medicationId: row.id,
|
||||
medicationName: row.name,
|
||||
totalPills: currentStock,
|
||||
currentPills: currentStock,
|
||||
plannerUsage: usageTotal,
|
||||
blisterSize: pillsPerBlister,
|
||||
blistersNeeded,
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Login error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
@@ -167,7 +167,10 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Extract username from configured claim
|
||||
const usernameClaim = env.OIDC_USERNAME_CLAIM;
|
||||
const username =
|
||||
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
|
||||
(userInfo as Record<string, string>)[usernameClaim] ||
|
||||
userInfo.preferred_username ||
|
||||
userInfo.email ||
|
||||
userInfo.sub;
|
||||
const oidcSubject = userInfo.sub;
|
||||
|
||||
if (!username || !oidcSubject) {
|
||||
@@ -210,7 +213,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// In dev: CORS_ORIGINS contains the frontend URL
|
||||
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error("[OIDC] Callback error:", err);
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -103,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ id: medications.id })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||
if (activeRows.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -132,11 +142,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
})
|
||||
);
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const outOfStockCount = activeRows.filter((r) => !r.enough).length;
|
||||
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
|
||||
|
||||
// Load prescription data for medications referenced in planner rows
|
||||
const medIds = rows.map((r) => r.medicationId).filter(Boolean);
|
||||
const medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
@@ -156,7 +166,7 @@ ${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows
|
||||
${activeRows
|
||||
.map((r) => {
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
@@ -191,7 +201,7 @@ ${getFooterPlain(language)}`;
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
const tableRows = activeRows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
@@ -312,7 +322,7 @@ ${getFooterPlain(language)}`;
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||
const pushMessage = `${summaryText}\n\n${rows
|
||||
const pushMessage = `${summaryText}\n\n${activeRows
|
||||
.map((r) => {
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
@@ -360,6 +370,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredLowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -374,9 +394,9 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Separate into 3 categories: empty, critical, and low stock
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
|
||||
// Build shared notification content (method-agnostic)
|
||||
const titleParts: string[] = [];
|
||||
@@ -489,8 +509,10 @@ ${getFooterPlain(language)}`;
|
||||
const buildTableRow = (row: LowStockItem) => {
|
||||
const isEmpty = row.medsLeft <= 0;
|
||||
const isCritical = row.isCritical !== false;
|
||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
||||
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
const safeName = escapeHtml(row.name);
|
||||
const safeMedsLeft = Number(row.medsLeft) || 0;
|
||||
const safeDaysLeft = Number(row.daysLeft) || 0;
|
||||
@@ -504,7 +526,7 @@ ${getFooterPlain(language)}`;
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
const tableRows = filteredLowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
@@ -566,7 +588,7 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
|
||||
@@ -583,12 +605,12 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Update the reminder state to record this notification was sent
|
||||
if (results.email || results.push) {
|
||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||
const singleChannel = results.email ? "email" : "push";
|
||||
const channel = results.email && results.push ? "both" : singleChannel;
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
const firstMed = lowStock[0];
|
||||
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(userId, "stock", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -618,14 +640,24 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredPrescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const lines = prescriptionLow.map((item) => {
|
||||
const lines = filteredPrescriptionLow.map((item) => {
|
||||
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
|
||||
if (item.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
@@ -640,7 +672,7 @@ ${getFooterPlain(language)}`;
|
||||
})}`;
|
||||
});
|
||||
|
||||
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
@@ -665,22 +697,23 @@ ${getFooterPlain(language)}`;
|
||||
});
|
||||
|
||||
const subject =
|
||||
prescriptionLow.length === 1
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
const alertText =
|
||||
emptyRx.length > 0
|
||||
? emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
||||
: lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const tableRows = prescriptionLow
|
||||
const tableRows = filteredPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
@@ -778,7 +811,7 @@ ${getFooterPlain(language)}`;
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
|
||||
try {
|
||||
const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message);
|
||||
@@ -794,7 +827,8 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
if (results.email || results.push) {
|
||||
const channel = results.email && results.push ? "both" : results.email ? "email" : "push";
|
||||
const singleChannel = results.email ? "email" : "push";
|
||||
const channel = results.email && results.push ? "both" : singleChannel;
|
||||
updateReminderSentTime("prescription", channel);
|
||||
await updateUserReminderSentTime(userId, "prescription", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -52,23 +52,34 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
if (!med) return reply.notFound("Medication not found");
|
||||
|
||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const effectivePacksAdded = isBottle ? 0 : packsAdded;
|
||||
const effectiveLoosePillsAdded = loosePillsAdded;
|
||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||
|
||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||
}
|
||||
|
||||
if (usePrescription) {
|
||||
if (!(med.prescriptionEnabled ?? false)) {
|
||||
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
|
||||
}
|
||||
const remaining = med.prescriptionRemainingRefills ?? 0;
|
||||
if (remaining <= 0) {
|
||||
if (remainingPrescriptionRefills <= 0) {
|
||||
return reply.status(409).send({ error: "No remaining prescription refills" });
|
||||
}
|
||||
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
|
||||
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
|
||||
}
|
||||
}
|
||||
|
||||
// Update medication stock
|
||||
const newPackCount = med.packCount + packsAdded;
|
||||
const newLooseTablets = med.looseTablets + loosePillsAdded;
|
||||
const newPackCount = med.packCount + effectivePacksAdded;
|
||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
|
||||
const consumedRefills = usePrescription ? (isBottle ? 1 : effectivePacksAdded) : 0;
|
||||
const newRemainingRefills = usePrescription
|
||||
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
|
||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||
: (med.prescriptionRemainingRefills ?? null);
|
||||
|
||||
await db
|
||||
@@ -77,8 +88,6 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
packCount: newPackCount,
|
||||
looseTablets: newLooseTablets,
|
||||
prescriptionRemainingRefills: newRemainingRefills,
|
||||
stockAdjustment: 0, // Reset offset since we're adding to base stock
|
||||
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||
@@ -89,16 +98,17 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
.values({
|
||||
medicationId: medId,
|
||||
userId,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
usedPrescription: usePrescription,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Calculate pills added for response (packageType-aware)
|
||||
const isBottle = (med.packageType ?? "blister") === "bottle";
|
||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
|
||||
const totalPillsAdded = isBottle
|
||||
? effectiveLoosePillsAdded
|
||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||
const newTotalPills = isBottle
|
||||
? newLooseTablets + (med.stockAdjustment ?? 0)
|
||||
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
||||
@@ -107,8 +117,8 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
success: true,
|
||||
refill: {
|
||||
id: refill.id,
|
||||
packsAdded,
|
||||
loosePillsAdded,
|
||||
packsAdded: effectivePacksAdded,
|
||||
loosePillsAdded: effectiveLoosePillsAdded,
|
||||
totalPillsAdded,
|
||||
refillDate: refill.refillDate,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
})
|
||||
.from(doseTracking)
|
||||
.where(eq(doseTracking.userId, userId));
|
||||
|
||||
// Group doses by medication ID
|
||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean }[]>();
|
||||
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 });
|
||||
}
|
||||
|
||||
// Fetch refill history for requested medications
|
||||
const result: Record<
|
||||
number,
|
||||
{
|
||||
dosesTaken: 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 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,
|
||||
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 type { FastifyInstance } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
@@ -33,6 +33,9 @@ export type UserSettings = {
|
||||
language: Language;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
upcomingTodayOnly: boolean;
|
||||
shareScheduleTodayOnly: boolean;
|
||||
swapDashboardMainSections: boolean;
|
||||
lastAutoEmailSent: string | null;
|
||||
lastNotificationType: string | null;
|
||||
lastNotificationChannel: string | null;
|
||||
@@ -69,6 +72,9 @@ type SettingsBody = {
|
||||
language: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
upcomingTodayOnly: boolean;
|
||||
shareScheduleTodayOnly: boolean;
|
||||
swapDashboardMainSections: boolean;
|
||||
};
|
||||
|
||||
type TestEmailBody = {
|
||||
@@ -119,6 +125,9 @@ function getDefaultSettings() {
|
||||
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
|
||||
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
|
||||
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
|
||||
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
|
||||
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
|
||||
swapDashboardMainSections: false,
|
||||
lastAutoEmailSent: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
@@ -178,6 +187,9 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
@@ -219,6 +231,9 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
||||
language: settings.language as Language,
|
||||
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||
lastNotificationType: settings.lastNotificationType,
|
||||
lastNotificationChannel: settings.lastNotificationChannel,
|
||||
@@ -239,7 +254,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: any, reply: any): Promise<number> {
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
// If auth is disabled, use the anonymous user
|
||||
if (!env.AUTH_ENABLED) {
|
||||
return getAnonymousUserId();
|
||||
@@ -283,6 +298,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
language: settings.language,
|
||||
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: settings.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
|
||||
// SMTP settings (from .env - shared/server-configured)
|
||||
smtpHost: process.env.SMTP_HOST ?? "",
|
||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
|
||||
@@ -349,6 +367,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
language: body.language ?? "en",
|
||||
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||
shareStockStatus: body.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: body.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: body.shareScheduleTodayOnly ?? false,
|
||||
swapDashboardMainSections: body.swapDashboardMainSections ?? false,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -544,7 +565,7 @@ export async function sendShoutrrrNotification(
|
||||
}
|
||||
|
||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
|
||||
@@ -154,6 +154,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
shareStockStatus: settings?.shareStockStatus ?? true,
|
||||
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
|
||||
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
getTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type Intake,
|
||||
type IntakeReminderState,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
@@ -321,7 +320,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
});
|
||||
|
||||
// Process each intake separately to track blisterIndex
|
||||
intakesWithReminders.forEach((intake, blisterIndex) => {
|
||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
|
||||
logger.debug(
|
||||
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
|
||||
@@ -684,7 +683,8 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
saveIntakeReminderState(state);
|
||||
|
||||
// Update global reminder state for UI display
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
updateReminderSentTime("intake", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
import type { ServiceLogger } from "../utils/logger.js";
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
parseBlisters,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
type ReminderState,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
@@ -119,10 +121,6 @@ export async function updateUserReminderSentTime(
|
||||
}
|
||||
}
|
||||
|
||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
return parseBlisters(row);
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
@@ -142,19 +140,153 @@ async function getMedicationsNeedingReminder(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const takenDoseRows = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||
const takenDoseTimestamps = new Map<string, number>();
|
||||
for (const dose of takenDoseRows) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
|
||||
if (!takenDoseIdsByMed.has(medId)) {
|
||||
takenDoseIdsByMed.set(medId, new Set());
|
||||
}
|
||||
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||
? rawTakenAt < 1_000_000_000_000
|
||||
? rawTakenAt * 1000
|
||||
: rawTakenAt
|
||||
: new Date(dose.takenAt).getTime();
|
||||
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||
}
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const blisters = parseBlistersFromRow(row);
|
||||
const totalPills =
|
||||
const intakes = parseIntakesJson(
|
||||
row.intakesJson,
|
||||
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||
row.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||
|
||||
const originalTotalPills =
|
||||
(row.packageType ?? "blister") === "bottle"
|
||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||
|
||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||
|
||||
let consumed = 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null];
|
||||
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const bIdx = parseInt(parts[1], 10);
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||
earlyTakenConsumed += blister.usage;
|
||||
}
|
||||
}
|
||||
|
||||
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||
});
|
||||
} else {
|
||||
blisters.forEach((blister, blisterIdx) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
const blisterStartDateOnly = new Date(
|
||||
blisterStart.getFullYear(),
|
||||
blisterStart.getMonth(),
|
||||
blisterStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||
|
||||
for (const doseId of takenDoseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||
const doseTimestamp = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumed += blister.usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||
|
||||
if (daysLeft === null) continue;
|
||||
|
||||
@@ -164,7 +296,7 @@ async function getMedicationsNeedingReminder(
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
medsLeft: totalPills,
|
||||
medsLeft: currentPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
isCritical,
|
||||
@@ -176,7 +308,11 @@ async function getMedicationsNeedingReminder(
|
||||
}
|
||||
|
||||
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
@@ -192,6 +328,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
||||
}));
|
||||
}
|
||||
|
||||
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||
export async function getMedicationsNeedingReminderForTests(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
lowStockDays: number,
|
||||
language: Language,
|
||||
stockCalculationMode: "automatic" | "manual"
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
daysLeft: number | null;
|
||||
depletionDate: string | null;
|
||||
isCritical: boolean;
|
||||
}>
|
||||
> {
|
||||
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||
}
|
||||
|
||||
async function sendReminderEmail(
|
||||
email: string,
|
||||
lowStock: LowStockItem[],
|
||||
@@ -267,8 +422,10 @@ async function sendReminderEmail(
|
||||
.map((row) => {
|
||||
const isEmpty = row.medsLeft <= 0;
|
||||
const isCritical = row.isCritical;
|
||||
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
|
||||
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
|
||||
const nonEmptyIcon = isCritical ? "🚨" : "⚠️";
|
||||
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
|
||||
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
|
||||
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
|
||||
return `
|
||||
<tr style="background: ${rowBg};">
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
|
||||
@@ -321,7 +478,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft
|
||||
---
|
||||
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
|
||||
|
||||
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
|
||||
const pluralSuffix = language === "de" ? "e" : "s";
|
||||
const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix;
|
||||
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
|
||||
|
||||
try {
|
||||
@@ -392,7 +550,8 @@ async function checkAndSendReminderForUser(
|
||||
settings.userId,
|
||||
settings.reminderDaysBefore,
|
||||
settings.lowStockDays,
|
||||
language
|
||||
language,
|
||||
settings.stockCalculationMode
|
||||
);
|
||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||
|
||||
@@ -452,7 +611,7 @@ async function checkAndSendReminderForUser(
|
||||
)
|
||||
);
|
||||
}
|
||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
@@ -462,7 +621,8 @@ async function checkAndSendReminderForUser(
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
@@ -472,7 +632,6 @@ async function checkAndSendReminderForUser(
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
const firstMed = allLowStock[0];
|
||||
const medNames = allLowStock.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
|
||||
}
|
||||
@@ -529,14 +688,15 @@ async function checkAndSendReminderForUser(
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
const alertText =
|
||||
emptyRx.length > 0
|
||||
? emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length })
|
||||
: lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const emptyAlert =
|
||||
emptyRx.length === 1
|
||||
? tr.prescriptionReminder.alertEmptySingle
|
||||
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
|
||||
const lowAlert =
|
||||
lowRx.length === 1
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
|
||||
|
||||
const tableRows = allPrescriptionLow
|
||||
.map((item) => {
|
||||
@@ -641,7 +801,7 @@ async function checkAndSendReminderForUser(
|
||||
);
|
||||
}
|
||||
}
|
||||
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
|
||||
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
|
||||
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
@@ -651,7 +811,8 @@ async function checkAndSendReminderForUser(
|
||||
|
||||
if (emailSuccess || shoutrrrSuccess) {
|
||||
const currentState = loadReminderState();
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
|
||||
const singleChannel = emailSuccess ? "email" : "push";
|
||||
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
|
||||
saveReminderState({
|
||||
lastAutoEmailSent: new Date().toISOString(),
|
||||
lastAutoEmailDate: today,
|
||||
@@ -661,7 +822,6 @@ async function checkAndSendReminderForUser(
|
||||
lastNotificationChannel: channel,
|
||||
});
|
||||
|
||||
const firstMed = allPrescriptionLow[0];
|
||||
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -294,8 +294,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
|
||||
// Should set cookies
|
||||
const cookies = response.cookies;
|
||||
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
|
||||
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
|
||||
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
|
||||
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should login case-insensitively with different username casing", async () => {
|
||||
@@ -393,7 +393,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -456,7 +456,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
|
||||
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -506,7 +506,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
@@ -604,7 +604,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -653,7 +653,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -689,7 +689,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
@@ -742,7 +742,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
|
||||
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
|
||||
|
||||
// Delete account
|
||||
const response = await app.inject({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
||||
import {
|
||||
|
||||
@@ -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);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(2);
|
||||
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
|
||||
// Each dose should have a takenAt timestamp
|
||||
for (const dose of data.doses) {
|
||||
expect(dose.takenAt).toBeTypeOf("number");
|
||||
|
||||
@@ -55,6 +55,7 @@ const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { settingsRoutes } = await import("../routes/settings.js");
|
||||
const { healthRoutes } = await import("../routes/health.js");
|
||||
const { refillRoutes } = await import("../routes/refills.js");
|
||||
const { reportRoutes } = await import("../routes/report.js");
|
||||
const { exportRoutes } = await import("../routes/export.js");
|
||||
|
||||
// =============================================================================
|
||||
@@ -99,6 +100,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -134,6 +138,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
@@ -258,11 +265,80 @@ describe("E2E Tests with Real Routes", () => {
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Real /medications/report-data route", () => {
|
||||
it("should return 400 for invalid payload", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 403 when requested medication is not owned by user", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [999999] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json().error).toBe("Access denied to medication");
|
||||
});
|
||||
|
||||
it("should aggregate taken/dismissed doses and refill history", async () => {
|
||||
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||
|
||||
// One taken dose and one dismissed dose for the same medication
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||
VALUES (?, ?, ?, 0)`,
|
||||
args: [userId, `${medId}-0-1735344000000`, 1735344000],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||
VALUES (?, ?, ?, 1)`,
|
||||
args: [userId, `${medId}-0-1735430400000-Daniel`, 1735430400],
|
||||
});
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [medId, userId, 2, 5, 1, 1735516800],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/report-data",
|
||||
payload: { medicationIds: [medId] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data[medId].dosesTaken).toBe(1);
|
||||
expect(data[medId].dosesDismissed).toBe(1);
|
||||
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||
expect(data[medId].lastDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||
expect(data[medId].refills).toHaveLength(1);
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 2,
|
||||
loosePillsAdded: 5,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
@@ -741,6 +817,39 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const data = getResponse.json();
|
||||
expect(data.repeatDailyReminders).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid language in lightweight language endpoint", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "fr" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("Invalid language");
|
||||
});
|
||||
|
||||
it("should create and update language via lightweight language endpoint", async () => {
|
||||
let response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "de" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings/language",
|
||||
payload: { language: "en" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const getResponse = await app.inject({ method: "GET", url: "/settings" });
|
||||
expect(getResponse.json().language).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1668,7 +1777,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
url: "/medications",
|
||||
});
|
||||
expect(medsResponse.statusCode).toBe(200);
|
||||
const med = medsResponse.json().find((m: any) => m.id === medId);
|
||||
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.prescriptionRemainingRefills).toBe(1);
|
||||
|
||||
const historyResponse = await app.inject({
|
||||
@@ -1806,8 +1915,10 @@ describe("E2E Tests with Real Routes", () => {
|
||||
const refills = response.json();
|
||||
expect(refills).toHaveLength(2);
|
||||
// Check both refills exist (order may vary)
|
||||
const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||
const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5);
|
||||
const hasPackRefill = refills.some((r: Record<string, unknown>) => r.packsAdded === 1 && r.loosePillsAdded === 0);
|
||||
const hasLooseRefill = refills.some(
|
||||
(r: Record<string, unknown>) => r.packsAdded === 0 && r.loosePillsAdded === 5
|
||||
);
|
||||
expect(hasPackRefill).toBe(true);
|
||||
expect(hasLooseRefill).toBe(true);
|
||||
});
|
||||
@@ -1885,7 +1996,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const meds = getResponse.json();
|
||||
const med = meds.find((m: any) => m.id === medId);
|
||||
const med = meds.find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med).toBeDefined();
|
||||
expect(med.stockAdjustment).toBe(-7);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
@@ -1931,7 +2042,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = getResponse.json().find((m: any) => m.id === medId);
|
||||
const med = getResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
@@ -2000,7 +2111,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// Verify adjustment is set
|
||||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
let med = getMeds.json().find((m: any) => m.id === medId);
|
||||
let med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(-10);
|
||||
|
||||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||||
@@ -2019,7 +2130,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// stockAdjustment should be reset to 0
|
||||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
med = getMeds.json().find((m: any) => m.id === medId);
|
||||
med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(0);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
});
|
||||
@@ -2063,7 +2174,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
// stockAdjustment should be preserved
|
||||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
const med = getMeds.json().find((m: any) => m.id === medId);
|
||||
const med = getMeds.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Preserve Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
@@ -2111,7 +2222,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
const med = data.find((m: any) => m.medicationId === medId);
|
||||
const med = data.find((m: Record<string, unknown>) => m.medicationId === medId);
|
||||
expect(med).toBeDefined();
|
||||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||||
@@ -2198,6 +2309,87 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.settings).toBeDefined();
|
||||
expect(data.settings.emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should include sensitive settings when requested", async () => {
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: "/settings",
|
||||
payload: {
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
repeatDailyReminders: false,
|
||||
lowStockDays: 30,
|
||||
normalStockDays: 90,
|
||||
highStockDays: 180,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "https://example.com/topic",
|
||||
emailStockReminders: false,
|
||||
emailIntakeReminders: false,
|
||||
emailPrescriptionReminders: false,
|
||||
shoutrrrStockReminders: true,
|
||||
shoutrrrIntakeReminders: true,
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
skipRemindersForTakenDoses: false,
|
||||
repeatRemindersEnabled: false,
|
||||
reminderRepeatIntervalMinutes: 30,
|
||||
maxNaggingReminders: 5,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/export?includeSensitive=true",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.settings.shoutrrrEnabled).toBe(true);
|
||||
expect(data.settings.shoutrrrUrl).toBe("https://example.com/topic");
|
||||
});
|
||||
|
||||
it("should gracefully export malformed date-like DB values", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Date Edge Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id as number;
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)`,
|
||||
args: [userId, `${medId}-0-1735344000000`, "not-a-date"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [medId, userId, 1, 0, 0, "still-not-a-date"],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, "date-edge-token", "Daniel", 30, "broken-date"],
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/export" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const data = response.json();
|
||||
expect(data.doseHistory).toHaveLength(1);
|
||||
expect(Number.isNaN(Date.parse(data.doseHistory[0].takenAt))).toBe(false);
|
||||
expect(data.refillHistory).toHaveLength(1);
|
||||
expect(Number.isNaN(Date.parse(data.refillHistory[0].refillDate))).toBe(false);
|
||||
expect(data.shareLinks).toHaveLength(1);
|
||||
expect(data.shareLinks[0].expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Real /import routes", () => {
|
||||
|
||||
@@ -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
|
||||
const mockExit = vi.fn();
|
||||
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
|
||||
vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
|
||||
|
||||
// Re-create the schema from env.ts for testing
|
||||
const EnvSchema = z.object({
|
||||
|
||||
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
const userId = 1; // Test user ID
|
||||
|
||||
// Helper to parse blisters from DB
|
||||
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
const usage = JSON.parse(row.usage_json || "[]") as number[];
|
||||
const every = JSON.parse(row.every_json || "[]") as number[];
|
||||
const start = JSON.parse(row.start_json || "[]") as string[];
|
||||
function parseBlisters(
|
||||
row: Record<string, unknown>
|
||||
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
|
||||
const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
|
||||
const every = JSON.parse((row.every_json as string) || "[]") as number[];
|
||||
const start = JSON.parse((row.start_json as string) || "[]") as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
return Array.from({ length: len }, (_, i) => ({
|
||||
usage: usage[i],
|
||||
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
args: [userId],
|
||||
});
|
||||
|
||||
let settings;
|
||||
let settings: Record<string, unknown> | undefined;
|
||||
if (settingsResult.rows.length > 0) {
|
||||
const s = settingsResult.rows[0];
|
||||
settings = {
|
||||
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
});
|
||||
|
||||
// POST /import
|
||||
app.post<{ Body: any }>("/import", async (request, reply) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
|
||||
app.post("/import", async (request, reply) => {
|
||||
const importData = request.body as any;
|
||||
|
||||
// Basic validation
|
||||
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
|
||||
// Import medications
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
for (const med of importData.medications || []) {
|
||||
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
|
||||
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
|
||||
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
|
||||
const usageJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
|
||||
);
|
||||
const everyJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
|
||||
);
|
||||
const startJson = JSON.stringify(
|
||||
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
|
||||
);
|
||||
const takenByJson = JSON.stringify(med.takenBy || []);
|
||||
|
||||
const result = await client.execute({
|
||||
|
||||
@@ -94,6 +94,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -129,6 +132,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
@@ -1330,8 +1336,8 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
});
|
||||
const meds = medsRes.json();
|
||||
const med1 = meds.find((m: any) => m.id === med1Id);
|
||||
const med2 = meds.find((m: any) => m.id === med2Id);
|
||||
const med1 = meds.find((m: Record<string, unknown>) => m.id === med1Id);
|
||||
const med2 = meds.find((m: Record<string, unknown>) => m.id === med2Id);
|
||||
|
||||
expect(med1.dismissedUntil).toBe("2025-01-15");
|
||||
expect(med2.dismissedUntil).toBe("2025-01-15");
|
||||
@@ -1373,7 +1379,7 @@ describe("Integration Tests", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
||||
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.dismissedUntil).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1443,7 +1449,7 @@ describe("Integration Tests", () => {
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = medsRes.json().find((m: any) => m.id === medId);
|
||||
const med = medsRes.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||
expect(med.dismissedUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import cookie from "@fastify/cookie";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type OidcMocks = {
|
||||
discovery: ReturnType<typeof vi.fn>;
|
||||
buildAuthorizationUrl: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function buildOidcApp(envOverrides: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
|
||||
const env = {
|
||||
OIDC_ENABLED: true,
|
||||
OIDC_ISSUER_URL: "https://issuer.example.com",
|
||||
OIDC_CLIENT_ID: "medassist-client",
|
||||
OIDC_CLIENT_SECRET: "medassist-client-secret",
|
||||
OIDC_REDIRECT_URI: "https://app.example.com/api/auth/oidc/callback",
|
||||
OIDC_SCOPES: "openid profile email",
|
||||
OIDC_AUTO_CREATE_USERS: true,
|
||||
OIDC_USERNAME_CLAIM: "preferred_username",
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
CORS_ORIGINS: "http://localhost:5173",
|
||||
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||
...envOverrides,
|
||||
};
|
||||
|
||||
vi.doMock("../plugins/env.js", () => ({ env }));
|
||||
|
||||
vi.doMock("../db/client.js", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 1, username: "sso-user" }]) })),
|
||||
})),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) })),
|
||||
},
|
||||
}));
|
||||
|
||||
const discovery = vi.fn().mockResolvedValue({ issuer: "https://issuer.example.com" });
|
||||
const buildAuthorizationUrl = vi.fn().mockImplementation((_cfg, params) => {
|
||||
const state = typeof params?.state === "string" ? params.state : "state";
|
||||
return new URL(`https://issuer.example.com/authorize?state=${state}`);
|
||||
});
|
||||
|
||||
vi.doMock("openid-client", () => ({
|
||||
discovery,
|
||||
buildAuthorizationUrl,
|
||||
authorizationCodeGrant: vi.fn(),
|
||||
fetchUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const { oidcRoutes } = await import("../routes/oidc.js");
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
app.decorate("config", {
|
||||
accessSecret: "test-jwt-secret-12345",
|
||||
refreshSecret: "test-refresh-secret-12345",
|
||||
accessTtl: 15 * 60,
|
||||
refreshTtl: 7 * 24 * 60 * 60,
|
||||
cookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/" },
|
||||
refreshCookieOptions: { httpOnly: true, sameSite: "lax", secure: false, path: "/auth" },
|
||||
});
|
||||
await app.register(oidcRoutes);
|
||||
await app.ready();
|
||||
|
||||
return {
|
||||
app,
|
||||
mocks: { discovery, buildAuthorizationUrl } as OidcMocks,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("OIDC routes", () => {
|
||||
it("returns 400 on login and callback when oidc is disabled", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: false });
|
||||
try {
|
||||
const login = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
const callback = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
expect(login.statusCode).toBe(400);
|
||||
expect(callback.statusCode).toBe(400);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects to provider and sets PKCE cookies on /auth/oidc/login", async () => {
|
||||
const { app, mocks } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/login" });
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toContain("https://issuer.example.com/authorize");
|
||||
expect(res.cookies.some((c) => c.name === "oidc_code_verifier")).toBe(true);
|
||||
expect(res.cookies.some((c) => c.name === "oidc_state")).toBe(true);
|
||||
expect(mocks.discovery).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildAuthorizationUrl).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects with provider error when callback contains error params", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/oidc/callback?error=access_denied&error_description=user_cancelled",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects when callback is missing required params", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("redirects when callback state validation fails", async () => {
|
||||
const { app } = await buildOidcApp({ OIDC_ENABLED: true });
|
||||
try {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/auth/oidc/callback?code=abc123&state=state123",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({
|
||||
|
||||
// Mock sendShoutrrrNotification from settings
|
||||
vi.mock("../routes/settings.js", async (importOriginal) => {
|
||||
const original = (await importOriginal()) as any;
|
||||
const original = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
sendShoutrrrNotification: mockSendShoutrrr,
|
||||
@@ -111,6 +111,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -146,6 +149,9 @@ async function createSchema(client: Client) {
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
share_schedule_today_only integer NOT NULL DEFAULT 0,
|
||||
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
@@ -168,6 +174,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
@@ -188,6 +195,18 @@ describe("Planner Routes", () => {
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
// Insert test medications so active-medication filters pass
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
@@ -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 cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Import from utils to avoid index.ts import side effects (server start)
|
||||
@@ -294,10 +294,18 @@ describe("Server Bootstrap", () => {
|
||||
refreshCookieOptions,
|
||||
});
|
||||
|
||||
expect((app as any).config.accessTtl).toBe(15);
|
||||
expect((app as any).config.refreshTtl).toBe(7);
|
||||
expect((app as any).config.cookieOptions.httpOnly).toBe(true);
|
||||
expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
const appWithConfig = app as unknown as {
|
||||
config: {
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: { httpOnly: boolean };
|
||||
refreshCookieOptions: { maxAge: number };
|
||||
};
|
||||
};
|
||||
expect(appWithConfig.config.accessTtl).toBe(15);
|
||||
expect(appWithConfig.config.refreshTtl).toBe(7);
|
||||
expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true);
|
||||
expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
@@ -364,15 +372,15 @@ describe("Server Bootstrap", () => {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Mock route plugins
|
||||
const healthRoutes = async (app: any) => {
|
||||
const healthRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/health", async () => ({ status: "ok" }));
|
||||
};
|
||||
|
||||
const authRoutes = async (app: any) => {
|
||||
const authRoutes = async (app: FastifyInstance) => {
|
||||
app.post("/auth/login", async () => ({ token: "mock" }));
|
||||
};
|
||||
|
||||
const medicationRoutes = async (app: any) => {
|
||||
const medicationRoutes = async (app: FastifyInstance) => {
|
||||
app.get("/medications", async () => []);
|
||||
};
|
||||
|
||||
|
||||
@@ -612,8 +612,8 @@ describe("Stock Calculation API", () => {
|
||||
const data = response.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const medA = data.find((d: any) => d.medicationName === "Med A");
|
||||
const medB = data.find((d: any) => d.medicationName === "Med B");
|
||||
const medA = data.find((d: Record<string, unknown>) => d.medicationName === "Med A");
|
||||
const medB = data.find((d: Record<string, unknown>) => d.medicationName === "Med B");
|
||||
|
||||
expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill
|
||||
expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../plugins/auth.js", () => ({
|
||||
requireAuth: async () => {},
|
||||
getAnonymousUserId: async () => 1,
|
||||
}));
|
||||
|
||||
const { medicationRoutes } = await import("../routes/medications.js");
|
||||
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM refill_history");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function seedAnonymousUser() {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||
args: [1, "anon", "anonymous"],
|
||||
});
|
||||
}
|
||||
|
||||
async function setStockMode(mode: "automatic" | "manual") {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||
VALUES (?, ?, 7, 365, 'en')`,
|
||||
args: [1, mode],
|
||||
});
|
||||
}
|
||||
|
||||
async function createMedication(options: {
|
||||
name: string;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
looseTablets?: number;
|
||||
stockAdjustment?: number;
|
||||
lastStockCorrectionAt?: number | null;
|
||||
isObsolete?: boolean;
|
||||
takenBy?: string[];
|
||||
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
looseTablets = 0,
|
||||
stockAdjustment = 0,
|
||||
lastStockCorrectionAt = null,
|
||||
isObsolete = false,
|
||||
takenBy = [],
|
||||
intakes,
|
||||
} = options;
|
||||
|
||||
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||
const intakesJson = JSON.stringify(
|
||||
intakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
takenBy: i.takenBy ?? null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
user_id, name, taken_by_json, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
pillsPerBlister,
|
||||
looseTablets,
|
||||
stockAdjustment,
|
||||
lastStockCorrectionAt,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
intakesJson,
|
||||
isObsolete ? 1 : 0,
|
||||
],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function markDoseTaken(options: {
|
||||
medicationId: number;
|
||||
blisterIdx: number;
|
||||
doseDateOnlyMs: number;
|
||||
takenAtMs: number;
|
||||
personSuffix?: string;
|
||||
}) {
|
||||
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||
});
|
||||
}
|
||||
|
||||
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate, endDate },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const rows = response.json();
|
||||
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||
expect(row).toBeDefined();
|
||||
return row;
|
||||
}
|
||||
|
||||
function toDateOnlyMs(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
}
|
||||
|
||||
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(medicationRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
await seedAnonymousUser();
|
||||
});
|
||||
|
||||
it("keeps automatic mode current stock in sync", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Auto Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Sync";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(10);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||
await setStockMode("manual");
|
||||
const medName = "Manual Correction";
|
||||
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
lastStockCorrectionAt: correctionMs,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan5DateOnly,
|
||||
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||
});
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: jan6DateOnly,
|
||||
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("counts early taken dose in automatic mode without drift", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Early Taken";
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
tomorrow.setHours(20, 0, 0, 0);
|
||||
const medicationId = await createMedication({
|
||||
name: medName,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||
});
|
||||
|
||||
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||
await markDoseTaken({
|
||||
medicationId,
|
||||
blisterIdx: 0,
|
||||
doseDateOnlyMs: tomorrowDateOnly,
|
||||
takenAtMs: now.getTime(),
|
||||
});
|
||||
|
||||
const rangeStart = new Date(now);
|
||||
rangeStart.setDate(now.getDate() - 1);
|
||||
const rangeEnd = new Date(now);
|
||||
rangeEnd.setDate(now.getDate() + 7);
|
||||
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(9);
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
});
|
||||
|
||||
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||
await setStockMode("automatic");
|
||||
const medName = "Mixed TakenBy";
|
||||
await createMedication({
|
||||
name: medName,
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Alice", "Bob"],
|
||||
intakes: [
|
||||
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||
],
|
||||
});
|
||||
|
||||
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||
|
||||
expect(schedulerRow).toBeDefined();
|
||||
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||
expect(usageRow.currentPills).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||
await setStockMode("automatic");
|
||||
await createMedication({
|
||||
name: "Obsolete Med",
|
||||
isObsolete: true,
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -191,7 +191,7 @@ export function parseIntakesJson(
|
||||
try {
|
||||
const parsed = JSON.parse(intakesJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((intake: any) => ({
|
||||
return parsed.map((intake: Record<string, unknown>) => ({
|
||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||
every: typeof intake.every === "number" ? intake.every : 1,
|
||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||
@@ -312,7 +312,7 @@ export type UpcomingIntake = {
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
@@ -388,7 +388,7 @@ export function getUpcomingIntakes(
|
||||
medName: string,
|
||||
intakes: Intake[],
|
||||
minutesBefore: number,
|
||||
medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
_medicationTakenBy: string[], // Medication-level takenBy as fallback
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
|
||||
+10
-2
@@ -2,14 +2,22 @@
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css", "frontend/e2e/**/*.ts", "frontend/playwright.config.ts"]
|
||||
"includes": [
|
||||
"backend/src/**/*.ts",
|
||||
"frontend/src/**/*.ts",
|
||||
"frontend/src/**/*.tsx",
|
||||
"frontend/src/**/*.css",
|
||||
"frontend/e2e/**/*.ts",
|
||||
"frontend/playwright.config.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
"noForEach": "off",
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_URL=http://backend-dev:3000
|
||||
ports:
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
@@ -97,7 +96,7 @@ test.describe("Dashboard with medications", () => {
|
||||
await expect(ibuprofenRow).toBeVisible();
|
||||
const rowText = await ibuprofenRow.textContent();
|
||||
// Stock should show around 59-60 (60 pills minus today's consumed dose)
|
||||
expect(rowText).toContain("59");
|
||||
expect((rowText ?? "").includes("59") || (rowText ?? "").includes("60")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should show today block in timeline", async ({ page }) => {
|
||||
@@ -141,7 +140,7 @@ test.describe("Dashboard with medications", () => {
|
||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||
|
||||
await takeBtn.click();
|
||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 5000 });
|
||||
@@ -154,20 +153,23 @@ test.describe("Dashboard with medications", () => {
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Normalize state first: if a dose is already taken, undo it so we can
|
||||
// always execute the same take -> undo flow deterministically.
|
||||
const existingUndo = todayBlock.locator("button.dose-btn.undo").first();
|
||||
if (await existingUndo.isVisible().catch(() => false)) {
|
||||
await existingUndo.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
// Mark a dose as taken first
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
await expect(takeBtn).toBeVisible({ timeout: 10000 });
|
||||
await takeBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for undo button to appear (confirms the take succeeded)
|
||||
const undoBtn = todayBlock.locator("button.dose-btn.undo").first();
|
||||
try {
|
||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
// Take might have been rate-limited — skip this test gracefully
|
||||
return;
|
||||
}
|
||||
await expect(undoBtn).toBeVisible({ timeout: 10000 });
|
||||
await undoBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
@@ -38,58 +38,58 @@ async function fillAndSaveMedication(
|
||||
intakes?: { usage: string; every: string }[];
|
||||
}
|
||||
): Promise<void> {
|
||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
||||
const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
|
||||
if (await openCreateBtn.isVisible().catch(() => false)) {
|
||||
await openCreateBtn.click();
|
||||
}
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
|
||||
if (opts.genericName) {
|
||||
await page.getByLabel(/Generic Name/i).fill(opts.genericName);
|
||||
await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
|
||||
}
|
||||
|
||||
const packageTypeSelect = form.locator("select.package-type-select");
|
||||
if (opts.packageType === "bottle") {
|
||||
await page.locator("select.package-type-select").selectOption("bottle");
|
||||
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
|
||||
await packageTypeSelect.selectOption("bottle");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.totalCapacity)
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||
} else {
|
||||
await page.locator("select.package-type-select").selectOption("blister");
|
||||
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
|
||||
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
|
||||
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
|
||||
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
|
||||
}
|
||||
|
||||
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
|
||||
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
|
||||
|
||||
// Fill intake schedules
|
||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||
for (let i = 0; i < intakes.length; i++) {
|
||||
if (i > 0) {
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
}
|
||||
const row = page.locator(".blister-row").nth(i);
|
||||
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
|
||||
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every);
|
||||
}
|
||||
|
||||
// Click Save — handle potential rate-limiting by retrying
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("form.form-grid button[type='submit']").click();
|
||||
|
||||
// Wait for the form to reset: commercial name becomes empty after successful save
|
||||
try {
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
|
||||
break; // Save succeeded
|
||||
} catch {
|
||||
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
|
||||
// Save might have been rate-limited — wait and retry
|
||||
await page.waitForTimeout(3000);
|
||||
// Re-fill the name in case form was partially reset
|
||||
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
|
||||
if (!currentValue) {
|
||||
await page.getByLabel(/Commercial Name/i).fill(opts.name);
|
||||
await packageTypeSelect.selectOption("blister");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
|
||||
if (opts.blistersPerPack)
|
||||
await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
|
||||
if (opts.pillsPerBlister)
|
||||
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
|
||||
if (opts.loosePills) {
|
||||
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
|
||||
if (await looseField.isVisible().catch(() => false)) {
|
||||
await looseField.fill(opts.loosePills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
|
||||
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
|
||||
|
||||
// Fill intake schedules
|
||||
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
for (let i = 0; i < intakes.length; i++) {
|
||||
if (i > 0) {
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
}
|
||||
const row = form.locator(".blister-row").nth(i);
|
||||
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||
}
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await form.locator("button[type='submit']").click();
|
||||
|
||||
// Verify the medication appears in the list (may need reload if GET was rate-limited)
|
||||
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
|
||||
try {
|
||||
@@ -105,8 +105,23 @@ async function fillAndSaveMedication(
|
||||
* Helper: save after editing (PUT) and wait for success.
|
||||
*/
|
||||
async function saveEdit(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("form.form-grid button[type='submit']").click();
|
||||
const submitBtn = form.locator("button[type='submit']");
|
||||
if (
|
||||
(await submitBtn.count()) > 0 &&
|
||||
(await submitBtn
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
await submitBtn.first().click();
|
||||
} else {
|
||||
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||
if (await closeBtn.isVisible().catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
}
|
||||
}
|
||||
// Wait for the list to update with the new name — retry with reload if rate-limited
|
||||
const medRow = page.locator(".med-row").filter({ hasText: medName });
|
||||
try {
|
||||
@@ -195,10 +210,16 @@ test.describe("Medication CRUD", () => {
|
||||
|
||||
test("should not save with empty commercial name", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Leave name empty — save button should be disabled
|
||||
// Saving without name should not create a medication row.
|
||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||
await expect(saveBtn).toBeDisabled();
|
||||
await expect(saveBtn).toBeVisible();
|
||||
await saveBtn.click();
|
||||
await expect(page.locator(".med-row")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("should reset form after saving a medication", async ({ page }) => {
|
||||
@@ -211,10 +232,12 @@ test.describe("Medication CRUD", () => {
|
||||
pillsPerBlister: "10",
|
||||
});
|
||||
|
||||
// Form should reset — title should say "New medication"
|
||||
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
|
||||
// Commercial name should be empty
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
|
||||
// Opening a fresh form after save should start with an empty commercial name.
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,14 +262,16 @@ test.describe("Medication CRUD", () => {
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Form title should say "Edit medication"
|
||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
|
||||
// Form title should say "Edit entry" (or legacy "Edit medication").
|
||||
await expect(
|
||||
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
|
||||
).toBeVisible();
|
||||
|
||||
// The name field should have the current value
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit");
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("After Edit");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("After Edit");
|
||||
|
||||
// Save the edit
|
||||
await saveEdit(page, "After Edit");
|
||||
@@ -268,29 +293,17 @@ test.describe("Medication CRUD", () => {
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("Modified Name");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Modified Name");
|
||||
|
||||
// Click Cancel
|
||||
await page.locator("form.form-grid button.ghost").click();
|
||||
await page
|
||||
.getByRole("button", { name: /Close|Cancel/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Original name should still be in the list
|
||||
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show refill section in edit mode", async ({ page }) => {
|
||||
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
// Click Edit
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
|
||||
// Refill section should be visible
|
||||
const refillSection = page.locator(".refill-section");
|
||||
await expect(refillSection).toBeVisible();
|
||||
await expect(refillSection.locator("button.success")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Delete medication", () => {
|
||||
@@ -311,12 +324,14 @@ test.describe("Medication CRUD", () => {
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Accept the native confirm() dialog
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await medRow.locator("button.danger").click();
|
||||
await page
|
||||
.locator(".confirm-modal-overlay, .modal-overlay")
|
||||
.getByRole("button", { name: /Delete/i })
|
||||
.click();
|
||||
|
||||
// Medication should be removed
|
||||
await expect(medRow).not.toBeVisible({ timeout: 5000 });
|
||||
await expect(medRow).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
// Already deleted via UI — clear tracked list
|
||||
createdMeds.length = 0;
|
||||
@@ -401,21 +416,27 @@ test.describe("Medication CRUD", () => {
|
||||
test.describe("Intake schedule management", () => {
|
||||
test("should add and remove intake schedule rows", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
await page
|
||||
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
|
||||
expect(await page.locator(".blister-row").count()).toBe(1);
|
||||
expect(await form.locator(".blister-row").count()).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(3);
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
expect(await form.locator(".blister-row").count()).toBe(3);
|
||||
|
||||
const removeBtn = page
|
||||
.locator(".blister-row")
|
||||
.locator("form.form-grid:visible .blister-row")
|
||||
.last()
|
||||
.getByRole("button", { name: /Remove/i });
|
||||
await removeBtn.click();
|
||||
expect(await page.locator(".blister-row").count()).toBe(2);
|
||||
expect(await form.locator(".blister-row").count()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,17 +28,32 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
||||
}
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.locator("button.info").click();
|
||||
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper: save edit and verify success */
|
||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
// Wait for any pending network before clicking save
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click save
|
||||
const saveBtn = page.locator("form.form-grid button[type='submit']");
|
||||
await saveBtn.click();
|
||||
const submitBtn = form.locator("button[type='submit']");
|
||||
if (
|
||||
(await submitBtn.count()) > 0 &&
|
||||
(await submitBtn
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
await submitBtn.first().click();
|
||||
} else {
|
||||
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
|
||||
if (await closeBtn.isVisible().catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for save request + re-fetch to complete
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -74,7 +89,7 @@ test.describe("Medication Editing", () => {
|
||||
await clickEditMed(page, "Edit GenName Med");
|
||||
|
||||
// Generic name should be empty initially
|
||||
const genericField = page.getByLabel(/Generic Name/i);
|
||||
const genericField = page.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||
await expect(genericField).toHaveValue("");
|
||||
|
||||
// Add a generic name
|
||||
@@ -85,7 +100,7 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Click edit again and verify the generic name was saved
|
||||
await clickEditMed(page, "Edit GenName Med");
|
||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid");
|
||||
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Acetylsalicylic acid");
|
||||
});
|
||||
|
||||
test("should add notes to an existing medication", async ({ page }) => {
|
||||
@@ -93,9 +108,10 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Edit Notes Med");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Notes should be empty initially
|
||||
const notesField = page.getByLabel(/Notes/i);
|
||||
const notesField = page.getByLabel(/(Notes|form\.notes)/i);
|
||||
await expect(notesField).toHaveValue("");
|
||||
|
||||
// Add notes text
|
||||
@@ -106,7 +122,7 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Verify notes were saved by clicking edit again
|
||||
await clickEditMed(page, "Edit Notes Med");
|
||||
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast");
|
||||
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Take with food after breakfast");
|
||||
});
|
||||
|
||||
test("should add taken-by person to a medication", async ({ page }) => {
|
||||
@@ -178,56 +194,22 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Expiry Date Med");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Set expiry date to 6 months from now
|
||||
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||
const expiryField = page.getByLabel(/Expiry Date/i);
|
||||
const expiryField = page.getByLabel(/(Expiry Date|form\.expiryDate)/i);
|
||||
await expiryField.fill(expiryDate);
|
||||
await expect(expiryField).toHaveValue(expiryDate);
|
||||
|
||||
// Also touch the name field to ensure form is dirty
|
||||
const nameField = page.getByLabel(/Commercial Name/i);
|
||||
const currentName = await nameField.inputValue();
|
||||
await nameField.fill(currentName);
|
||||
// Expiry change itself is enough to persist in the current edit flow.
|
||||
|
||||
await saveEditAndVerify(page, "Expiry Date Med");
|
||||
|
||||
// Verify expiry date was saved
|
||||
await clickEditMed(page, "Expiry Date Med");
|
||||
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate);
|
||||
});
|
||||
|
||||
test("should use refill feature to add stock in edit mode", async ({ page }) => {
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: "Refill Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
})
|
||||
);
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Refill Test Med");
|
||||
|
||||
// Refill section should be visible in edit mode
|
||||
const refillSection = page.locator(".refill-section");
|
||||
await expect(refillSection).toBeVisible();
|
||||
|
||||
// Set refill values: 2 packs + 5 loose pills
|
||||
await refillSection.getByLabel(/Packs/i).fill("2");
|
||||
await refillSection.getByLabel(/Loose pills/i).fill("5");
|
||||
|
||||
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
|
||||
const preview = refillSection.locator(".refill-preview");
|
||||
await expect(preview).toBeVisible();
|
||||
expect(await preview.textContent()).toContain("45");
|
||||
|
||||
// Click the refill button
|
||||
await refillSection.locator("button.success").click();
|
||||
|
||||
// Wait for the refill to be processed
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByLabel(/(Expiry Date|form\.expiryDate)/i)).toHaveValue(expiryDate);
|
||||
});
|
||||
|
||||
test("should edit intake schedule usage and interval", async ({ page }) => {
|
||||
@@ -247,11 +229,12 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Edit Intake Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Change intake from 1 pill daily to 2 pills every 7 days
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
|
||||
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
|
||||
const usageField = intakeRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i);
|
||||
const everyField = intakeRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||
|
||||
await usageField.fill("2");
|
||||
await everyField.fill("7");
|
||||
@@ -264,8 +247,8 @@ test.describe("Medication Editing", () => {
|
||||
// Verify the changes persisted
|
||||
await clickEditMed(page, "Edit Intake Med");
|
||||
const savedRow = page.locator(".blister-row").first();
|
||||
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
|
||||
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
|
||||
await expect(savedRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i)).toHaveValue("2");
|
||||
await expect(savedRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i)).toHaveValue("7");
|
||||
});
|
||||
|
||||
test("should add a second intake schedule row", async ({ page }) => {
|
||||
@@ -285,18 +268,19 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Add Intake Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Should have 1 intake row initially
|
||||
await expect(page.locator(".blister-row")).toHaveCount(1);
|
||||
|
||||
// Add a second intake
|
||||
await page.getByRole("button", { name: /Intake/i }).click();
|
||||
await page.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
await expect(page.locator(".blister-row")).toHaveCount(2);
|
||||
|
||||
// Fill the new intake row
|
||||
const secondRow = page.locator(".blister-row").nth(1);
|
||||
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
|
||||
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
|
||||
await secondRow.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill("0.5");
|
||||
await secondRow.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill("7");
|
||||
|
||||
await saveEditAndVerify(page, "Add Intake Med");
|
||||
|
||||
@@ -322,6 +306,7 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "Reminder Toggle Med");
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Find the remind checkbox in the intake row
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
@@ -357,20 +342,24 @@ test.describe("Medication Editing", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await clickEditMed(page, "PackType Change Med");
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
|
||||
// Should be blister type initially
|
||||
const packageSelect = page.locator("select.package-type-select");
|
||||
const packageSelect = form.locator("select.package-type-select");
|
||||
await expect(packageSelect).toHaveValue("blister");
|
||||
|
||||
// Blister-specific fields should be visible
|
||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
||||
// Blister-specific fields are shown in the Package tab.
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to bottle
|
||||
await packageSelect.selectOption("bottle");
|
||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i)).toBeVisible();
|
||||
|
||||
// Fill bottle-specific fields
|
||||
await page.getByLabel(/Total Capacity/i).fill("120");
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill("120");
|
||||
|
||||
await saveEditAndVerify(page, "PackType Change Med");
|
||||
|
||||
@@ -386,13 +375,15 @@ test.describe("Medication Editing", () => {
|
||||
await clickEditMed(page, "Multi Edit Med");
|
||||
|
||||
// Change the name
|
||||
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med");
|
||||
await page.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Fully Edited Med");
|
||||
|
||||
// Add generic name
|
||||
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
|
||||
await page.getByLabel(/(Generic Name|form\.genericName)/i).fill("Ibuprofen Lysinate");
|
||||
|
||||
// Add notes
|
||||
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water.");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await page.getByLabel(/(Notes|form\.notes)/i).fill("Morning dose only. Take with plenty of water.");
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Add a taken-by person
|
||||
const takenByInput = page.locator(".tag-input-container input");
|
||||
@@ -404,9 +395,9 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Verify all changes persisted
|
||||
await clickEditMed(page, "Fully Edited Med");
|
||||
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
|
||||
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
|
||||
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
|
||||
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Fully Edited Med");
|
||||
await expect(page.getByLabel(/(Generic Name|form\.genericName)/i)).toHaveValue("Ibuprofen Lysinate");
|
||||
await expect(page.getByLabel(/(Notes|form\.notes)/i)).toContainText("Morning dose only");
|
||||
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,11 +10,17 @@ import { authFile, navigateTo, test } from "./fixtures";
|
||||
test.describe("Medications Page", () => {
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
const visibleMedForm = (page: Page) => page.locator("form.form-grid:visible").first();
|
||||
|
||||
async function openMedicationForm(page: Page) {
|
||||
await navigateTo(page, "/medications");
|
||||
const newMedicationButton = page.getByRole("button", { name: /New medication/i });
|
||||
if (await newMedicationButton.isVisible().catch(() => false)) {
|
||||
await newMedicationButton.click();
|
||||
const nameField = visibleMedForm(page).getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
if (await nameField.isVisible().catch(() => false)) return;
|
||||
|
||||
const newEntryButton = page.getByRole("button", { name: /(new (entry|medication)|form\.newEntry)/i });
|
||||
if (await newEntryButton.isVisible().catch(() => false)) {
|
||||
await newEntryButton.click();
|
||||
await expect(nameField).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +35,8 @@ test.describe("Medications Page", () => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
// Should show either medication entries or the new medication form
|
||||
const listTitle = page.locator("h2").filter({ hasText: /Medication list/i });
|
||||
const formTitle = page.locator("h2").filter({ hasText: /New medication/i });
|
||||
const listTitle = page.locator("h2").filter({ hasText: /(Medication list|form\.medicationList)/i });
|
||||
const formTitle = page.locator("h2").filter({ hasText: /(New (entry|medication)|form\.newEntry)/i });
|
||||
|
||||
const hasList = await listTitle.isVisible().catch(() => false);
|
||||
const hasForm = await formTitle.isVisible().catch(() => false);
|
||||
@@ -40,85 +46,92 @@ test.describe("Medications Page", () => {
|
||||
|
||||
test("should display the medication form with required fields", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
const commercialName = page.getByLabel(/Commercial Name/i);
|
||||
const commercialName = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
await expect(commercialName).toBeVisible();
|
||||
|
||||
// Package type selector should exist
|
||||
await expect(page.getByText(/Package Type/i)).toBeVisible();
|
||||
await expect(form.getByText(/(Package Type|form\.packageType)/i)).toBeVisible();
|
||||
|
||||
// Intake schedule section should exist
|
||||
await expect(page.getByText(/Intake schedule/i)).toBeVisible();
|
||||
// Tabbed form should expose navigation to Package/Schedule sections
|
||||
await expect(page.getByRole("tab", { name: /Package/i })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /Schedule/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should fill in medication details", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
const nameField = page.getByLabel(/Commercial Name/i);
|
||||
const nameField = form.getByLabel(/(Commercial Name|form\.commercialName)/i);
|
||||
await nameField.fill("Test Aspirin");
|
||||
await expect(nameField).toHaveValue("Test Aspirin");
|
||||
|
||||
const genericField = page.getByLabel(/Generic Name/i);
|
||||
const genericField = form.getByLabel(/(Generic Name|form\.genericName)/i);
|
||||
await genericField.fill("Acetylsalicylic acid");
|
||||
await expect(genericField).toHaveValue("Acetylsalicylic acid");
|
||||
});
|
||||
|
||||
test("should have stock inventory fields", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Stock fields should be visible
|
||||
await expect(page.getByLabel(/^Packs$/i)).toBeVisible();
|
||||
// Package tab should expose stock-related fields for at least one package mode.
|
||||
const packsField = form.getByLabel(/(^Packs$|form\.packs)/i).first();
|
||||
const totalField = form.getByText(/(Total \(pills\)|Total Capacity|form\.totalCapacity)/i).first();
|
||||
|
||||
// Either blister or bottle fields depending on package type
|
||||
const blistersField = page.getByLabel(/Blisters per pack/i);
|
||||
const pillsField = page.getByLabel(/Pills per blister/i);
|
||||
const capacityField = page.getByLabel(/Total Capacity/i);
|
||||
const hasPacks = await packsField.isVisible().catch(() => false);
|
||||
const hasTotal = await totalField.isVisible().catch(() => false);
|
||||
|
||||
const hasBlister = await blistersField.isVisible().catch(() => false);
|
||||
const hasBottle = await capacityField.isVisible().catch(() => false);
|
||||
|
||||
expect(hasBlister || hasBottle).toBeTruthy();
|
||||
expect(hasPacks || hasTotal).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should toggle package type between blister and bottle", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
|
||||
// Find the package type radio buttons or selector
|
||||
const blisterOption = page.getByText(/Blister Pack/i);
|
||||
const bottleOption = page.getByText(/Pill Bottle/i);
|
||||
const blisterOption = form.getByText(/(Blister Pack|form\.packageType\.blister)/i);
|
||||
const bottleOption = form.getByText(/(Pill Bottle|form\.packageType\.bottle)/i);
|
||||
|
||||
if (await blisterOption.isVisible().catch(() => false)) {
|
||||
// Switch to bottle
|
||||
await bottleOption.click();
|
||||
// Bottle-specific fields should appear
|
||||
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
|
||||
await expect(form.getByLabel(/(Total Capacity|form\.totalCapacity)/i)).toBeVisible();
|
||||
|
||||
// Switch back to blister
|
||||
await blisterOption.click();
|
||||
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
|
||||
await expect(form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("should have intake schedule with add button", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
// Intake schedule section
|
||||
const scheduleSection = page.getByText(/Intake schedule/i);
|
||||
await expect(scheduleSection).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: /Schedule/i, selected: true })).toBeVisible();
|
||||
|
||||
// Should have at least one intake entry
|
||||
await expect(page.getByText(/Usage \(pills\)|Every \(days\)/i).first()).toBeVisible();
|
||||
await expect(
|
||||
form.getByText(/(Usage \(pills\)|Every \(days\)|form\.blisters\.usage|form\.blisters\.everyDays)/i).first()
|
||||
).toBeVisible();
|
||||
|
||||
// Should have an add intake button
|
||||
const addIntake = page.getByRole("button", { name: /Intake/i });
|
||||
const addIntake = form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i });
|
||||
await expect(addIntake).toBeVisible();
|
||||
});
|
||||
|
||||
test("should have save and cancel buttons", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
// Fill in a name to make the form dirty
|
||||
await page.getByLabel(/Commercial Name/i).fill("Test");
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Test");
|
||||
|
||||
// Save button
|
||||
const saveButton = page.getByRole("button", { name: /Save|Add Medication/i });
|
||||
@@ -127,9 +140,10 @@ test.describe("Medications Page", () => {
|
||||
|
||||
test("should prevent navigation with unsaved changes", async ({ page }) => {
|
||||
await openMedicationForm(page);
|
||||
const form = visibleMedForm(page);
|
||||
|
||||
// Fill in the form to create unsaved changes
|
||||
await page.getByLabel(/Commercial Name/i).fill("Unsaved Medication");
|
||||
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill("Unsaved Medication");
|
||||
|
||||
// Try to navigate away
|
||||
await page.locator('button.pill:has-text("Dashboard")').click();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
authFile,
|
||||
createMedicationViaAPI,
|
||||
deleteAllMedicationsViaAPI,
|
||||
deleteMedicationViaAPI,
|
||||
expect,
|
||||
navigateTo,
|
||||
type TestMedication,
|
||||
@@ -194,7 +193,7 @@ test.describe("Schedule with medications", () => {
|
||||
await expect(todayBlock).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
if (!(await takeBtn.isVisible().catch(() => false))) return;
|
||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||
|
||||
await takeBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { authFile, navigateTo, test } from "./fixtures";
|
||||
import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateTo, test } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Schedule / Timeline E2E Tests
|
||||
@@ -10,6 +10,32 @@ import { authFile, navigateTo, test } from "./fixtures";
|
||||
test.describe("Schedule Timeline", () => {
|
||||
test.use({ storageState: authFile });
|
||||
|
||||
const seededName = "Schedule Smoke Seed";
|
||||
const startThreeDaysAgo = (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 3);
|
||||
d.setHours(8, 0, 0, 0);
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
})();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
await createMedicationViaAPI({
|
||||
name: seededName,
|
||||
packageType: "blister",
|
||||
packCount: 2,
|
||||
blistersPerPack: 2,
|
||||
pillsPerBlister: 10,
|
||||
takenBy: ["Daniel"],
|
||||
intakes: [{ usage: 1, every: 1, start: startThreeDaysAgo, intakeRemindersEnabled: false, takenBy: "Daniel" }],
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await deleteAllMedicationsViaAPI();
|
||||
});
|
||||
|
||||
test("should have timeline container in DOM", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
@@ -44,22 +70,16 @@ test.describe("Schedule Timeline", () => {
|
||||
test("should show past days toggle when medications exist", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// Past days toggle only appears when there are scheduled medications
|
||||
// Past days toggle appears when there are scheduled medications
|
||||
const pastToggle = page.locator(".past-days-toggle");
|
||||
const hasPastToggle = await pastToggle.isVisible().catch(() => false);
|
||||
|
||||
// Just verify it doesn't crash — visibility depends on medication data
|
||||
expect(typeof hasPastToggle).toBe("boolean");
|
||||
await expect(pastToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test("should expand/collapse past days on click", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const pastToggle = page.locator(".past-days-toggle");
|
||||
if (!(await pastToggle.isVisible().catch(() => false))) {
|
||||
// No medications — past days toggle not shown
|
||||
return;
|
||||
}
|
||||
await expect(pastToggle).toBeVisible();
|
||||
|
||||
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
|
||||
|
||||
@@ -75,62 +95,56 @@ test.describe("Schedule Timeline", () => {
|
||||
test("should show future days toggle when medications exist", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// Future days toggle only appears when there are scheduled medications
|
||||
// Future days toggle appears when there are scheduled medications
|
||||
const futureToggle = page.locator(".future-days-toggle");
|
||||
const hasFutureToggle = await futureToggle.isVisible().catch(() => false);
|
||||
expect(typeof hasFutureToggle).toBe("boolean");
|
||||
await expect(futureToggle).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display day blocks in timeline", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// There should be at least one day block (today)
|
||||
// With medications there should be day blocks; otherwise empty-state is expected.
|
||||
const dayBlocks = page.locator(".day-block");
|
||||
expect(await dayBlocks.count()).toBeGreaterThanOrEqual(0);
|
||||
const dayBlockCount = await dayBlocks.count();
|
||||
if (dayBlockCount === 0) {
|
||||
await expect(page.getByText(/No medications/i)).toBeVisible();
|
||||
return;
|
||||
}
|
||||
expect(dayBlockCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("should highlight today block", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
// If there are medications, today should be highlighted
|
||||
// With medications, today should be highlighted
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
const hasTodayBlock = await todayBlock.isVisible().catch(() => false);
|
||||
|
||||
// Today block exists only if there are medications with schedules
|
||||
if (hasTodayBlock) {
|
||||
await expect(todayBlock).toBeVisible();
|
||||
// Should have a day divider with date text
|
||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||
}
|
||||
await expect(todayBlock).toBeVisible();
|
||||
await expect(todayBlock.locator(".day-date")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show day summary with progress", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
if (await todayBlock.isVisible().catch(() => false)) {
|
||||
const summary = todayBlock.locator(".day-summary");
|
||||
await expect(summary).toBeVisible();
|
||||
}
|
||||
await expect(todayBlock).toBeVisible();
|
||||
const summary = todayBlock.locator(".day-summary");
|
||||
await expect(summary).toBeVisible();
|
||||
});
|
||||
|
||||
test("should collapse/expand a day block", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
if (await todayBlock.isVisible().catch(() => false)) {
|
||||
const dayDivider = todayBlock.locator(".day-divider");
|
||||
await dayDivider.click();
|
||||
await expect(todayBlock).toBeVisible();
|
||||
const dayDivider = todayBlock.locator(".day-divider");
|
||||
await dayDivider.click();
|
||||
|
||||
// Check if it toggled collapsed state
|
||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
|
||||
// Click again to restore
|
||||
await dayDivider.click();
|
||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
await dayDivider.click();
|
||||
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
|
||||
|
||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||
}
|
||||
expect(isCollapsed).not.toBe(isCollapsedAfter);
|
||||
});
|
||||
|
||||
test("should show overview table with stock status", async ({ page }) => {
|
||||
@@ -138,23 +152,15 @@ test.describe("Schedule Timeline", () => {
|
||||
|
||||
// Overview table has class .table.table-7
|
||||
const overviewTable = page.locator(".table.table-7");
|
||||
const hasTable = await overviewTable.isVisible().catch(() => false);
|
||||
|
||||
// Table only visible if medications exist
|
||||
if (hasTable) {
|
||||
// Table should have a header row
|
||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||
}
|
||||
await expect(overviewTable).toBeVisible();
|
||||
await expect(overviewTable.locator(".table-head")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should display share button in schedules section", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
||||
|
||||
const shareBtn = page.locator("button.share-btn");
|
||||
// Share button only visible if there are takenBy users
|
||||
const hasShareBtn = await shareBtn.isVisible().catch(() => false);
|
||||
|
||||
// Just verify it's either visible or not (no crash)
|
||||
expect(typeof hasShareBtn).toBe("boolean");
|
||||
await expect(shareBtn).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,10 +130,7 @@ test.describe("Settings Page", () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabledToggle) {
|
||||
// All toggles disabled (no notification channels configured) — skip
|
||||
return;
|
||||
}
|
||||
test.skip(!enabledToggle, "All notification toggles are disabled in this environment");
|
||||
|
||||
const checkbox = enabledToggle.locator('input[type="checkbox"]');
|
||||
const initialState = await checkbox.isChecked();
|
||||
|
||||
@@ -160,7 +160,7 @@ test.describe("Share Schedule", () => {
|
||||
|
||||
// Should show the shared schedule page (not the login page)
|
||||
// Wait for either the schedule content or an error
|
||||
const sharedContent = page.locator(".shared-schedule, .share-page");
|
||||
const _sharedContent = page.locator(".shared-schedule, .share-page");
|
||||
const dayBlock = page.locator(".day-block");
|
||||
const medName = page.getByText(MED_ALICE);
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@
|
||||
# LOG_LEVEL=warn|error|fatal|silent → access logs suppressed
|
||||
# =============================================================================
|
||||
|
||||
case "${LOG_LEVEL:-info}" in
|
||||
# Normalize: lowercase + trim whitespace
|
||||
level=$(printf '%s' "${LOG_LEVEL:-info}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||
|
||||
case "$level" in
|
||||
warn|error|fatal|silent)
|
||||
export NGINX_ACCESS_LOG="off"
|
||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL} → access_log off"
|
||||
;;
|
||||
*)
|
||||
export NGINX_ACCESS_LOG="/dev/stdout"
|
||||
echo "[nginx-entrypoint] LOG_LEVEL=${LOG_LEVEL:-info} → access_log /dev/stdout"
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -43,5 +43,9 @@ server {
|
||||
# Timeout for uploads
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
# Prevent buffering upstream responses to temp files (images can be large)
|
||||
# nginx streams directly to client instead of buffering the full response
|
||||
proxy_max_temp_file_size 0;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+131
-110
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.10.3",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.10.3",
|
||||
"version": "1.12.0",
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.7",
|
||||
"i18next": "^25.8.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -17,7 +18,7 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.15",
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -27,7 +28,7 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
@@ -48,23 +49,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
|
||||
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
|
||||
"integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.4",
|
||||
"@csstools/css-color-parser": "^3.1.0",
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"lru-cache": "^11.2.4"
|
||||
"@csstools/css-calc": "^3.0.0",
|
||||
"@csstools/css-color-parser": "^4.0.1",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -72,9 +73,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
|
||||
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
|
||||
"version": "6.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
|
||||
"integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -82,13 +83,13 @@
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.1.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.4"
|
||||
"lru-cache": "^11.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -404,9 +405,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz",
|
||||
"integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.1.tgz",
|
||||
"integrity": "sha512-8c5DZQl1hfpLRlTZ21W5Ef2R314E4UJUEtkMbo303ElTVe6fYtapwldv7tZlgwm+9YP0Mhk7dUSTkOY8nQ2/2w==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -420,20 +421,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.3.15",
|
||||
"@biomejs/cli-darwin-x64": "2.3.15",
|
||||
"@biomejs/cli-linux-arm64": "2.3.15",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.3.15",
|
||||
"@biomejs/cli-linux-x64": "2.3.15",
|
||||
"@biomejs/cli-linux-x64-musl": "2.3.15",
|
||||
"@biomejs/cli-win32-arm64": "2.3.15",
|
||||
"@biomejs/cli-win32-x64": "2.3.15"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.1",
|
||||
"@biomejs/cli-darwin-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64": "2.4.1",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.1",
|
||||
"@biomejs/cli-linux-x64": "2.4.1",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.1",
|
||||
"@biomejs/cli-win32-arm64": "2.4.1",
|
||||
"@biomejs/cli-win32-x64": "2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz",
|
||||
"integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-wKiX2znbgFRaivRplSbu53hiREp1ohlGRuWqOL90IPetLi5E32tkiMYu8uSLXVzDgbIVM58WsesPaczIVtJkOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -448,9 +449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.15.tgz",
|
||||
"integrity": "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-rxLYVg3skeXh9K0om7JdkKcCdvtqrF9ECZ7dsmLuYObboK7DZ1J0z6xc2NGKSXw+cEQo3ie6NQgWBcdGJ16yQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -465,9 +466,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.15.tgz",
|
||||
"integrity": "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-nlGO5KzoEKhGj2i3QXyyNCeFk8SVwyes0wo0/X9w943darnlAHfi8MYYunPf8lsz5C0JaH6pJYB6D9HnDwUPQA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -482,9 +483,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.15.tgz",
|
||||
"integrity": "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-Brwh/QL3wfX5UyZcyEamS1Q+EF8Q7ud+MS5mq/9BWX2ArfxQlgsqlukwK92xrGpXWcspXkSG9U0CoxvCZZkTKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -499,9 +500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.15.tgz",
|
||||
"integrity": "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-Rmhm/mQ/3pejy1WtWLKurV1fN6zvCrqKz/ART2ZzgqY4ozL07uys5R9jA0A+yLjA79JTkcpIe85ygXv0FnSPRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -516,9 +517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.15.tgz",
|
||||
"integrity": "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.1.tgz",
|
||||
"integrity": "sha512-kz1QpA+PXouNyWw2VzeoMlzMn99hlyOC/El2uSy+DS8gcb6tOsKEeZ5e2onnFIfZKe9AeKMFbTowDNLXwjwGjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -533,9 +534,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.15.tgz",
|
||||
"integrity": "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.1.tgz",
|
||||
"integrity": "sha512-e+PrlbQ/tez7W9EAzzCGUH1ovq31kR5r8sfCDzasrmoADLnDafet8pA8LdXnt0GwkeOem5Hz6WHCVZPRmaXiXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -550,9 +551,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.3.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.15.tgz",
|
||||
"integrity": "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.1.tgz",
|
||||
"integrity": "sha512-kfjOCzvaHC7olg8pmEuSsYzHntxdipkAGzr5nFiaEU2EPDWRE/myqUBaFDl9pHqEc8yEtQFiXF945PlTSkuOTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -566,10 +567,23 @@
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
|
||||
"integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -583,13 +597,13 @@
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -603,17 +617,17 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
|
||||
"integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -627,21 +641,21 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
"@csstools/color-helpers": "^6.0.1",
|
||||
"@csstools/css-calc": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -655,16 +669,16 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
|
||||
"integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
|
||||
"version": "1.0.27",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
|
||||
"integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -676,15 +690,12 @@
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -698,7 +709,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -2139,25 +2150,25 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
|
||||
"integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
|
||||
"integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^4.1.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
|
||||
"@asamuzakjp/css-color": "^4.1.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.26",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.4"
|
||||
"lru-cache": "^11.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -2438,9 +2449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.7",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.7.tgz",
|
||||
"integrity": "sha512-ttxxc5+67S/0hhoeVdEgc1lRklZhdfcUSEPp1//uUG2NB88X3667gRsDar+ZWQFdysnOsnb32bcoMsa4mtzhkQ==",
|
||||
"version": "25.8.10",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.10.tgz",
|
||||
"integrity": "sha512-CtPJLMAz1G8sxo+mIzfBjGgLxWs7d6WqIjlmmv9BTsOat4pJIfwZ8cm07n3kFS6bP9c6YwsYutYrwsEeJVBo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2540,16 +2551,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "28.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz",
|
||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||
"version": "28.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"cssstyle": "^5.3.7",
|
||||
"cssstyle": "^6.0.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
@@ -2560,7 +2572,7 @@
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.0",
|
||||
"undici": "^7.20.0",
|
||||
"undici": "^7.21.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
@@ -2627,6 +2639,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.574.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz",
|
||||
"integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
+13
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.11.1",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,15 +14,20 @@
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "rm -rf test-results && playwright test --project=chromium --project=chromium-data --workers=1; find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr | sed \"s/^/file '/\" | sed \"s/$/'/ \" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm && open -a 'Google Chrome' test-results/all-tests.webm",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
||||
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
|
||||
"test:e2e:ui": "playwright test --config=playwright.stable.config.ts --ui",
|
||||
"test:e2e:headed": "playwright test --config=playwright.stable.config.ts --headed",
|
||||
"test:e2e:debug": "playwright test --config=playwright.stable.config.ts --debug",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.7",
|
||||
"i18next": "^25.8.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.574.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -30,7 +35,7 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.15",
|
||||
"@biomejs/biome": "^2.4.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -40,7 +45,7 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* Playwright E2E Testing Configuration
|
||||
*
|
||||
* Run E2E tests with:
|
||||
* npm run test:e2e - Run tests in headless mode
|
||||
* npm run test:e2e:ui - Run tests with Playwright UI
|
||||
* npm run test:e2e:headed - Run tests in headed mode
|
||||
*
|
||||
* Before running tests, ensure both backend and frontend are running:
|
||||
* docker compose -f docker-compose.dev.yml up
|
||||
*
|
||||
* Or run them separately:
|
||||
* cd backend && npm run dev
|
||||
* cd frontend && npm run dev
|
||||
*/
|
||||
|
||||
// Base URL for the frontend dev server
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
|
||||
export default defineConfig({
|
||||
// Directory containing test files
|
||||
testDir: "./e2e",
|
||||
|
||||
// Test file pattern
|
||||
testMatch: "**/*.spec.ts",
|
||||
|
||||
// Maximum time one test can run
|
||||
timeout: 30 * 1000,
|
||||
|
||||
// Maximum time to wait for expect assertions
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry failed tests (more retries on CI)
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Opt out of parallel tests on CI
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: process.env.CI
|
||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||
|
||||
// Shared settings for all projects
|
||||
use: {
|
||||
// Base URL for page.goto() calls
|
||||
baseURL,
|
||||
|
||||
// Collect trace on first retry
|
||||
trace: "on-first-retry",
|
||||
|
||||
// Capture screenshot on failure
|
||||
screenshot: "only-on-failure",
|
||||
|
||||
// Record video for every test so runs can be reviewed
|
||||
video: "on",
|
||||
|
||||
// Default viewport size
|
||||
viewport: { width: 1280, height: 720 },
|
||||
|
||||
// Wait for network idle before considering navigation complete
|
||||
navigationTimeout: 30000,
|
||||
|
||||
// Accept cookies and local storage
|
||||
actionTimeout: 5000,
|
||||
},
|
||||
|
||||
// Configure projects for multiple browsers
|
||||
projects: [
|
||||
// Setup project for authentication state
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Desktop Chrome — primary test browser, always runs
|
||||
// Excludes data/crud tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
retries: 1,
|
||||
},
|
||||
|
||||
// Desktop Firefox — runs locally and optionally in CI
|
||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// Desktop Safari — runs locally and optionally in CI
|
||||
// Excludes data/crud/edit/status/schedule tests (those run in chromium-data to avoid DB conflicts)
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
testIgnore: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
// Data tests — only Chromium, run serially to avoid DB conflicts
|
||||
// These tests create/edit/delete medications and must not run concurrently
|
||||
// across browsers since all share the same backend database.
|
||||
{
|
||||
name: "chromium-data",
|
||||
testMatch: /.*-(?:data|crud|edit|status|schedule)\.spec\.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
fullyParallel: false,
|
||||
retries: 1,
|
||||
},
|
||||
],
|
||||
|
||||
// Directory for test output files (screenshots, traces, videos)
|
||||
outputDir: "test-results/",
|
||||
|
||||
// Web server configuration — automatically start dev servers in CI
|
||||
webServer: [
|
||||
{
|
||||
command: "cd ../backend && npm run dev",
|
||||
url: "http://localhost:3000/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
],
|
||||
});
|
||||
export default buildPlaywrightConfig(false);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlaywrightConfig } from "./playwright.base.config";
|
||||
|
||||
export default buildPlaywrightConfig(false);
|
||||
+43
-13
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AboutModal,
|
||||
Lightbox,
|
||||
@@ -112,6 +112,7 @@ function AppRouter() {
|
||||
// =============================================================================
|
||||
|
||||
function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
// Get shared state from AppContext
|
||||
const ctx = useAppContext();
|
||||
const {
|
||||
@@ -139,7 +140,10 @@ function AppContent() {
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockLoosePills,
|
||||
setEditStockLoosePills,
|
||||
editStockSaving,
|
||||
editStockMedication,
|
||||
openRefillModal,
|
||||
closeRefillModal,
|
||||
openEditStockModal,
|
||||
@@ -289,18 +293,25 @@ function AppContent() {
|
||||
// Close tooltips on scroll/touch (for mobile)
|
||||
useEffect(() => {
|
||||
const closeAllTooltips = () => {
|
||||
document.querySelectorAll(".info-tooltip.tooltip-active").forEach((el) => {
|
||||
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
||||
el.classList.remove("tooltip-active");
|
||||
});
|
||||
};
|
||||
|
||||
const handleTooltipClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains("info-tooltip")) {
|
||||
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
||||
if (tooltipTrigger) {
|
||||
// Close other tooltips first
|
||||
closeAllTooltips();
|
||||
// Toggle this one
|
||||
target.classList.add("tooltip-active");
|
||||
tooltipTrigger.classList.add("tooltip-active");
|
||||
// Position tooltip above the icon on mobile
|
||||
if (window.innerWidth <= 640) {
|
||||
const rect = tooltipTrigger.getBoundingClientRect();
|
||||
// Place tooltip bottom edge just above the icon
|
||||
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
||||
}
|
||||
} else {
|
||||
closeAllTooltips();
|
||||
}
|
||||
@@ -351,9 +362,11 @@ function AppContent() {
|
||||
}
|
||||
}, [meds, selectedMed, setSelectedMed]);
|
||||
|
||||
const stockCorrectionMed = selectedMed ?? (showEditStockModal ? editStockMedication : null);
|
||||
|
||||
const handleSubmitStockCorrection = async (medId: number) => {
|
||||
if (!selectedMed) return;
|
||||
await ctx.submitStockCorrection(medId, selectedMed, loadMeds);
|
||||
if (!stockCorrectionMed) return;
|
||||
await ctx.submitStockCorrection(medId, stockCorrectionMed, loadMeds);
|
||||
};
|
||||
|
||||
// For MedDetailModal: refill without form update (not editing)
|
||||
@@ -361,11 +374,19 @@ function AppContent() {
|
||||
await ctx.submitRefill(medId, null, () => {}, loadMeds, usePrescription);
|
||||
};
|
||||
|
||||
// Wrapper for openEditStockModal (provides selectedMed and coverage)
|
||||
const handleOpenEditStockModal = () => {
|
||||
if (selectedMed) {
|
||||
openEditStockModal(selectedMed, coverage);
|
||||
}
|
||||
const handleOpenMedicationEdit = () => {
|
||||
if (!selectedMed) return;
|
||||
const medId = selectedMed.id;
|
||||
setShowImageLightbox(false);
|
||||
setShowRefillModal(false);
|
||||
setShowEditStockModal(false);
|
||||
setSelectedMed(null);
|
||||
navigate(`/medications?editMedId=${medId}`);
|
||||
};
|
||||
|
||||
const handleOpenEditStockFromDetail = () => {
|
||||
if (!selectedMed) return;
|
||||
openEditStockModal(selectedMed, coverage);
|
||||
};
|
||||
|
||||
function openProfile() {
|
||||
@@ -415,18 +436,20 @@ function AppContent() {
|
||||
|
||||
{/* Medication Detail Modal */}
|
||||
<MedDetailModal
|
||||
selectedMed={selectedMed}
|
||||
selectedMed={stockCorrectionMed}
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
showImageLightbox={showImageLightbox}
|
||||
showRefillModal={showRefillModal}
|
||||
showEditStockModal={showEditStockModal}
|
||||
editStockOnly={showEditStockModal && !selectedMed}
|
||||
onClose={closeMedDetail}
|
||||
onOpenImageLightbox={openImageLightbox}
|
||||
onCloseImageLightbox={closeImageLightbox}
|
||||
onOpenRefillModal={openRefillModal}
|
||||
onCloseRefillModal={closeRefillModal}
|
||||
onOpenEditStockModal={handleOpenEditStockModal}
|
||||
onOpenMedicationEdit={handleOpenMedicationEdit}
|
||||
onOpenEditStockModal={handleOpenEditStockFromDetail}
|
||||
onCloseEditStockModal={closeEditStockModal}
|
||||
refillPacks={refillPacks}
|
||||
onRefillPacksChange={setRefillPacks}
|
||||
@@ -443,6 +466,8 @@ function AppContent() {
|
||||
onEditStockFullBlistersChange={setEditStockFullBlisters}
|
||||
editStockPartialBlisterPills={editStockPartialBlisterPills}
|
||||
onEditStockPartialBlisterPillsChange={setEditStockPartialBlisterPills}
|
||||
editStockLoosePills={editStockLoosePills}
|
||||
onEditStockLoosePillsChange={setEditStockLoosePills}
|
||||
editStockSaving={editStockSaving}
|
||||
onSubmitStockCorrection={handleSubmitStockCorrection}
|
||||
/>
|
||||
@@ -454,6 +479,11 @@ function AppContent() {
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onClearUser={() => {
|
||||
setSelectedUser(null);
|
||||
// Replace the userFilter history entry so it doesn't remain on the stack
|
||||
window.history.replaceState(null, "");
|
||||
}}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,8 +51,18 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content about-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useUnsavedChanges } from "../context";
|
||||
import type { ThemePreference } from "../hooks";
|
||||
import { useTheme } from "../hooks";
|
||||
import { useAuth } from "./Auth";
|
||||
|
||||
|
||||
@@ -756,7 +756,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
{t("common.close", "Close")}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -12,7 +12,7 @@ export interface ConfirmModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
confirmVariant?: "primary" | "danger" | "success";
|
||||
confirmVariant?: "primary" | "danger" | "success" | "warning";
|
||||
overlayClassName?: string;
|
||||
}
|
||||
|
||||
@@ -27,9 +27,31 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<div
|
||||
className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`}
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content confirm-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* DateInput - Custom date input that displays dates in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted date string on top of a native <input type="date">,
|
||||
* so the browser calendar popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDate, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateInput({ value, placeholder, className, ...rest }: DateInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
const displayValue = value ? formatDate(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`date-input-wrapper ${className ?? ""}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||
}}
|
||||
>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="date" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* DateTimeInput - Custom datetime input that displays date+time in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted datetime string on top of a native <input type="datetime-local">,
|
||||
* so the browser datetime popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026, 20:30 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDateTime, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateTimeInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, placeholder, className, ...rest }: DateTimeInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
// datetime-local value is "YYYY-MM-DDTHH:MM" — formatDateTime handles this format
|
||||
const displayValue = value ? formatDateTime(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`date-input-wrapper ${className ?? ""}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleClick();
|
||||
}}
|
||||
>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="datetime-local" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,19 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
style={{ maxWidth: "450px" }}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
@@ -53,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
||||
</div>
|
||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("exportImport.cancelButton")}
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface LightboxProps {
|
||||
src: string;
|
||||
@@ -11,7 +12,19 @@ export interface LightboxProps {
|
||||
}
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
@@ -19,10 +32,18 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="lightbox-container">
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,18 @@ export default function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content profile-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
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;
|
||||
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.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.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
|
||||
* Allows sharing schedule view for a specific person
|
||||
*/
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
@@ -38,86 +40,120 @@ export function ShareDialog({
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content share-dialog-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close tooltip-trigger"
|
||||
onClick={onClose}
|
||||
aria-label={closeLabel}
|
||||
data-tooltip={closeLabel}
|
||||
>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className="share-dialog-header">
|
||||
<h2>🔗 {t("share.title")}</h2>
|
||||
<h2>
|
||||
<Link2 size={18} aria-hidden="true" /> {t("share.title")}
|
||||
</h2>
|
||||
<p className="share-dialog-description">{t("share.description")}</p>
|
||||
</div>
|
||||
|
||||
{sharePeople.length === 0 ? (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t("share.noPeople")}</p>
|
||||
</div>
|
||||
) : shareLink ? (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button className="btn-copy" onClick={onCopyShareLink}>
|
||||
{shareCopied ? "✓" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
</button>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPerson")}</label>
|
||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{(() => {
|
||||
if (sharePeople.length === 0) {
|
||||
return (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t("share.noPeople")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (shareLink) {
|
||||
return (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-copy icon-only tooltip-trigger"
|
||||
onClick={onCopyShareLink}
|
||||
aria-label={copyLabel}
|
||||
data-tooltip={copyLabel}
|
||||
>
|
||||
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
</button>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPerson")}</label>
|
||||
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPeriod")}</label>
|
||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t("share.selectPeriod")}</label>
|
||||
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
|
||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
</button>
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -209,7 +209,7 @@ export function SharedSchedule() {
|
||||
|
||||
// Get dose ID - for per-intake takenBy, the ID already has the person suffix
|
||||
// This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
|
||||
function getDoseId(doseId: string, _person: string | null): string {
|
||||
function _getDoseId(doseId: string, _person: string | null): string {
|
||||
// The dose.id already includes the person suffix if there's a per-intake takenBy
|
||||
return doseId;
|
||||
}
|
||||
@@ -479,7 +479,8 @@ export function SharedSchedule() {
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||
const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null];
|
||||
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople;
|
||||
|
||||
let timeBasedConsumed = 0;
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
@@ -579,11 +580,13 @@ export function SharedSchedule() {
|
||||
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
|
||||
return status.className;
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||
}
|
||||
|
||||
// Whether to show stock status indicators on the shared schedule
|
||||
const showStock = data?.shareStockStatus !== false;
|
||||
const showOnlyToday = data?.shareScheduleTodayOnly === true && (data?.upcomingTodayOnly ?? true);
|
||||
|
||||
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
|
||||
function isDoseIdDone(doseId: string): boolean {
|
||||
@@ -606,7 +609,7 @@ export function SharedSchedule() {
|
||||
const missedPastDoseIds = useMemo(() => {
|
||||
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
|
||||
}, [pastDays, takenDoses, dismissedDoses, data]);
|
||||
}, [pastDays, isDoseIdDone]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -714,14 +717,20 @@ export function SharedSchedule() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="shared-schedule-period">
|
||||
{t("share.period")}:{" "}
|
||||
{data.scheduleDays === 30
|
||||
? t("dashboard.schedules.1month")
|
||||
: data.scheduleDays === 90
|
||||
? t("dashboard.schedules.3months")
|
||||
: t("dashboard.schedules.6months")}
|
||||
</p>
|
||||
{!showOnlyToday &&
|
||||
(() => {
|
||||
const periodLabel =
|
||||
data.scheduleDays === 30
|
||||
? t("dashboard.schedules.1month")
|
||||
: data.scheduleDays === 90
|
||||
? t("dashboard.schedules.3months")
|
||||
: t("dashboard.schedules.6months");
|
||||
return (
|
||||
<p className="shared-schedule-period">
|
||||
{t("share.period")}: {periodLabel}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</header>
|
||||
|
||||
<div className="timeline">
|
||||
@@ -730,7 +739,8 @@ export function SharedSchedule() {
|
||||
) : (
|
||||
<>
|
||||
{/* Past days (when expanded) — rendered above toggle */}
|
||||
{showPastDays &&
|
||||
{!showOnlyToday &&
|
||||
showPastDays &&
|
||||
pastDays.map((day) => {
|
||||
// Get ALL dose IDs for this day (for total count and yellow styling)
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||
@@ -757,14 +767,18 @@ export function SharedSchedule() {
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
|
||||
const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : "";
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`}
|
||||
>
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||||
}}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
@@ -817,11 +831,18 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
<div className="med-name-stack">
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -837,9 +858,12 @@ export function SharedSchedule() {
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
@@ -859,7 +883,8 @@ export function SharedSchedule() {
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -875,7 +900,8 @@ export function SharedSchedule() {
|
||||
);
|
||||
})}
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 &&
|
||||
{!showOnlyToday &&
|
||||
pastDays.length > 0 &&
|
||||
(() => {
|
||||
const missedCount = missedPastDoseIds.length;
|
||||
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
|
||||
@@ -894,6 +920,9 @@ export function SharedSchedule() {
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays);
|
||||
}}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
@@ -941,6 +970,9 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||
}}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
@@ -982,11 +1014,18 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
<div className="med-name-stack">
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -1006,9 +1045,12 @@ export function SharedSchedule() {
|
||||
>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div
|
||||
@@ -1030,7 +1072,8 @@ export function SharedSchedule() {
|
||||
title={t("dose.markAsTaken")}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
✓
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1047,7 +1090,8 @@ export function SharedSchedule() {
|
||||
})()}
|
||||
|
||||
{/* Future days toggle — identical to DashboardPage */}
|
||||
{futureDays.length > 0 &&
|
||||
{!showOnlyToday &&
|
||||
futureDays.length > 0 &&
|
||||
(() => {
|
||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
|
||||
@@ -1058,6 +1102,9 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
|
||||
onClick={() => setShowFutureDays(!showFutureDays)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays);
|
||||
}}
|
||||
>
|
||||
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
|
||||
<span className="future-days-label">
|
||||
@@ -1079,7 +1126,8 @@ export function SharedSchedule() {
|
||||
})()}
|
||||
|
||||
{/* Future days (when expanded) — identical to DashboardPage */}
|
||||
{showFutureDays &&
|
||||
{!showOnlyToday &&
|
||||
showFutureDays &&
|
||||
futureDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
@@ -1099,6 +1147,9 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed);
|
||||
}}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
@@ -1139,11 +1190,18 @@ export function SharedSchedule() {
|
||||
<div
|
||||
className={med?.imageUrl ? "med-avatar clickable" : ""}
|
||||
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</div>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
<div className="med-name-stack">
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">{med.genericName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
|
||||
@@ -1159,9 +1217,12 @@ export function SharedSchedule() {
|
||||
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg &&
|
||||
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="dose-checks">
|
||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
@@ -1181,7 +1242,8 @@ export function SharedSchedule() {
|
||||
title={t("dose.markAsTaken")}
|
||||
disabled={true}
|
||||
>
|
||||
✓
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1215,7 +1277,13 @@ export function SharedSchedule() {
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={closeLightbox}>
|
||||
<div
|
||||
className="lightbox-overlay"
|
||||
onClick={closeLightbox}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") closeLightbox();
|
||||
}}
|
||||
>
|
||||
<button className="lightbox-close" onClick={closeLightbox}>
|
||||
×
|
||||
</button>
|
||||
@@ -1224,6 +1292,7 @@ export function SharedSchedule() {
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -15,6 +16,7 @@ export interface UserFilterModalProps {
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onClearUser: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
@@ -24,17 +26,28 @@ export function UserFilterModal({
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onClearUser,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content user-meds-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content user-meds-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
@@ -47,22 +60,61 @@ export function UserFilterModal({
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||
const status = medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
||||
: getStockStatus(null, getMedTotal(med), settings);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onClearUser();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onClearUser();
|
||||
onOpenMedDetail(med);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
{personIntakes.length > 0 && (
|
||||
<div className="user-med-intakes">
|
||||
{personIntakes.map((intake, idx) => {
|
||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<span key={idx} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||
{t("modal.at")} {timeStr}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
@@ -15,6 +17,7 @@ export type { MobileEditModalProps } from "./MobileEditModal";
|
||||
export { MobileEditModal } from "./MobileEditModal";
|
||||
export { PasswordInput } from "./PasswordInput";
|
||||
export { default as ProfileModal } from "./ProfileModal";
|
||||
export { default as ReportModal } from "./ReportModal";
|
||||
export type { ShareDialogProps } from "./ShareDialog";
|
||||
export { ShareDialog } from "./ShareDialog";
|
||||
export { SharedSchedule } from "./SharedSchedule";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, use
|
||||
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -119,7 +119,10 @@ export interface AppContextValue {
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockLoosePills: number;
|
||||
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
editStockMedication: Medication | null;
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
submitRefill: (
|
||||
medId: number,
|
||||
@@ -175,8 +178,20 @@ export interface AppContextValue {
|
||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
pendingImportData: unknown;
|
||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||
importResult: { medications: number; doses: number; shares: number } | null;
|
||||
setImportResult: React.Dispatch<React.SetStateAction<{ medications: number; doses: number; shares: number } | null>>;
|
||||
importResult: {
|
||||
medications: number;
|
||||
doses: number;
|
||||
refills: number;
|
||||
shares: number;
|
||||
} | null;
|
||||
setImportResult: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
medications: number;
|
||||
doses: number;
|
||||
refills: number;
|
||||
shares: number;
|
||||
} | null>
|
||||
>;
|
||||
handleExport: (includeImages?: boolean) => Promise<void>;
|
||||
handleImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleImportConfirm: () => Promise<void>;
|
||||
@@ -197,7 +212,17 @@ export interface AppContextValue {
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
|
||||
|
||||
const AppContext = (() => {
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
|
||||
};
|
||||
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
|
||||
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
|
||||
}
|
||||
return globalRef[APP_CONTEXT_SINGLETON_KEY];
|
||||
})();
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -237,7 +262,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||
const [importResult, setImportResult] = useState<{ medications: number; doses: number; shares: number } | null>(null);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
medications: number;
|
||||
doses: number;
|
||||
refills: number;
|
||||
shares: number;
|
||||
} | null>(null);
|
||||
|
||||
// Load user-specific scheduleDays when user changes
|
||||
useEffect(() => {
|
||||
@@ -270,15 +300,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Computed values - combine app language with timezone region for locale
|
||||
const systemLocale = getSystemLocale(i18n.language);
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, systemLocale, true),
|
||||
[medications.meds, systemLocale]
|
||||
);
|
||||
const activeMeds = useMemo(() => medications.meds.filter((m) => !m.isObsolete), [medications.meds]);
|
||||
const schedule = useMemo(() => buildSchedulePreview(activeMeds, systemLocale, true), [activeMeds, systemLocale]);
|
||||
|
||||
const coverage = useMemo(
|
||||
() =>
|
||||
calculateCoverage(
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -287,7 +315,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
doses.takenDoseTimestamps
|
||||
),
|
||||
[
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -351,7 +379,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
// Normal/High stock
|
||||
return "success";
|
||||
});
|
||||
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
|
||||
const fallbackStatus = statuses.includes("warning") ? "warning" : "success";
|
||||
return statuses.includes("danger") ? "danger" : fallbackStatus;
|
||||
},
|
||||
[coverageByMed, depletionByMed, settingsHook.settings.lowStockDays]
|
||||
);
|
||||
@@ -430,8 +459,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(
|
||||
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
|
||||
);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
@@ -486,8 +515,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
share.openShareDialog(activeMeds);
|
||||
}, [share, activeMeds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
@@ -507,9 +536,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const dateStr = new Date().toISOString().split("T")[0];
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
|
||||
const userPart = user?.username ? `-${user.username}` : "";
|
||||
a.href = url;
|
||||
a.download = `${t("exportImport.downloadFilename")}-${dateStr}.json`;
|
||||
a.download = `${t("exportImport.downloadFilename")}${userPart}-${dateStr}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -519,7 +550,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
setExporting(false);
|
||||
},
|
||||
[t]
|
||||
[t, user?.username]
|
||||
);
|
||||
|
||||
// Handle file selection for import
|
||||
@@ -583,6 +614,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setImportResult({
|
||||
medications: data.imported?.medications || 0,
|
||||
doses: data.imported?.doseHistory || 0,
|
||||
refills: data.imported?.refillHistory || 0,
|
||||
shares: data.imported?.shareLinks || 0,
|
||||
});
|
||||
|
||||
@@ -625,6 +657,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
|
||||
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
|
||||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
|
||||
settings.upcomingTodayOnly !== savedSettings.upcomingTodayOnly ||
|
||||
settings.shareScheduleTodayOnly !== savedSettings.shareScheduleTodayOnly ||
|
||||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
|
||||
);
|
||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||
@@ -755,7 +789,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setEditStockFullBlisters: refill.setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills: refill.editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills: refill.setEditStockPartialBlisterPills,
|
||||
editStockLoosePills: refill.editStockLoosePills,
|
||||
setEditStockLoosePills: refill.setEditStockLoosePills,
|
||||
editStockSaving: refill.editStockSaving,
|
||||
editStockMedication: refill.editStockMedication,
|
||||
loadRefillHistory: refill.loadRefillHistory,
|
||||
submitRefill: refill.submitRefill,
|
||||
submitStockCorrection: refill.submitStockCorrection,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
|
||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||
export type { UseMedicationsReturn } from "./useMedications";
|
||||
export { useMedications } from "./useMedications";
|
||||
export { useModalHistory } from "./useModalHistory";
|
||||
export type { UseRefillReturn } from "./useRefill";
|
||||
export { useRefill } from "./useRefill";
|
||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||
|
||||
@@ -41,6 +41,7 @@ export const defaultForm = (): FormState => ({
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
@@ -189,6 +190,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setEditingId(med.id);
|
||||
setTakenByInput(""); // Clear tag input when starting edit
|
||||
setFormSaved(true); // Existing medication is already saved
|
||||
setFieldErrors({}); // Prevent one-frame stale error highlight from previous/default form state
|
||||
|
||||
// Parse intakes - prefer new format, fallback to legacy blisters
|
||||
const intakesFromApi =
|
||||
@@ -214,6 +216,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
|
||||
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
|
||||
|
||||
const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : "";
|
||||
const editForm: FormState = {
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
@@ -222,14 +225,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
packCount: String(med.packCount),
|
||||
blistersPerPack: String(med.blistersPerPack),
|
||||
pillsPerBlister: String(med.pillsPerBlister),
|
||||
totalPills: med.totalPills
|
||||
? String(med.totalPills)
|
||||
: med.packageType === "bottle" && med.looseTablets
|
||||
? String(med.looseTablets)
|
||||
: "",
|
||||
totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills,
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
@@ -260,6 +260,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
setTakenByInput("");
|
||||
setFieldErrors({});
|
||||
setFormSaved(false);
|
||||
const newForm = defaultForm();
|
||||
setForm(newForm);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications", { credentials: "include" })
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
|
||||
@@ -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 { getMedTotal, getPackageSize } from "../types";
|
||||
|
||||
@@ -24,7 +24,10 @@ export interface UseRefillReturn {
|
||||
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockPartialBlisterPills: number;
|
||||
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockLoosePills: number;
|
||||
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
|
||||
editStockSaving: boolean;
|
||||
editStockMedication: Medication | null;
|
||||
|
||||
// Actions
|
||||
loadRefillHistory: (medId: number) => Promise<void>;
|
||||
@@ -56,7 +59,9 @@ export function useRefill(): UseRefillReturn {
|
||||
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
||||
const [editStockLoosePills, setEditStockLoosePills] = useState(0);
|
||||
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
@@ -132,42 +137,60 @@ export function useRefill(): UseRefillReturn {
|
||||
if (!selectedMed) return;
|
||||
setEditStockSaving(true);
|
||||
try {
|
||||
// Auto-convert: handle full blister and negative partial blister
|
||||
let finalFullBlisters = editStockFullBlisters;
|
||||
let finalPartialPills = editStockPartialBlisterPills;
|
||||
// Clamp all fields to non-negative values.
|
||||
let finalFullBlisters = Math.max(0, editStockFullBlisters);
|
||||
let finalPartialPills =
|
||||
selectedMed.packageType === "bottle"
|
||||
? Math.max(0, editStockPartialBlisterPills)
|
||||
: Math.max(0, editStockPartialBlisterPills);
|
||||
const finalLoosePills = Math.max(0, editStockLoosePills);
|
||||
|
||||
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||
finalFullBlisters += 1;
|
||||
finalPartialPills = 0;
|
||||
// Canonicalize blister values: partial overflow becomes additional full blisters.
|
||||
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
|
||||
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
|
||||
finalPartialPills %= selectedMed.pillsPerBlister;
|
||||
}
|
||||
|
||||
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||
finalFullBlisters -= 1;
|
||||
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||
}
|
||||
// Structural max = sealed package capacity only (no looseTablets offset).
|
||||
const structuralMax =
|
||||
selectedMed.packageType === "bottle"
|
||||
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
|
||||
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
|
||||
|
||||
// Ensure we don't go negative
|
||||
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||
|
||||
// What the user says they have RIGHT NOW = the new DB total
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
// Use getPackageSize() which handles both blister and bottle types correctly
|
||||
const baseTotal = getPackageSize(selectedMed);
|
||||
// For blister meds, only sealed pills are capped to package size.
|
||||
// Loose pills are extra and can be above package size.
|
||||
const desiredTotal =
|
||||
selectedMed.packageType === "bottle"
|
||||
? Math.min(structuralMax, Math.max(0, finalPartialPills))
|
||||
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
|
||||
finalLoosePills;
|
||||
|
||||
// The "base" from DB structure used to compute stockAdjustment differs by type:
|
||||
// - Bottle: looseTablets is the base (not changed during correction)
|
||||
// - Blister: use structuralMax + finalLoosePills as the new base so that
|
||||
// updating looseTablets in the DB doesn't cause a stale-split display bug.
|
||||
const baseTotal =
|
||||
selectedMed.packageType === "bottle"
|
||||
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
|
||||
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||
// For blister corrections also send the new looseTablets value so the DB
|
||||
// reflects the actual loose count (avoids stale-split display on reload).
|
||||
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
|
||||
stockAdjustment: newStockAdjustment,
|
||||
};
|
||||
if (selectedMed.packageType !== "bottle") {
|
||||
patchBody.looseTablets = finalLoosePills;
|
||||
}
|
||||
|
||||
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Close edit stock modal via history back
|
||||
@@ -182,7 +205,7 @@ export function useRefill(): UseRefillReturn {
|
||||
}
|
||||
setEditStockSaving(false);
|
||||
},
|
||||
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
|
||||
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
||||
);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
@@ -198,25 +221,51 @@ export function useRefill(): UseRefillReturn {
|
||||
|
||||
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
|
||||
if (!selectedMed) return;
|
||||
setEditStockMedication(selectedMed);
|
||||
// Get current stock from coverage (after consumption)
|
||||
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
|
||||
const dbTotal = getMedTotal(selectedMed);
|
||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||
const currentStock = Math.max(0, medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal);
|
||||
|
||||
// Simply divide into full blisters and partial
|
||||
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
||||
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
||||
// Bottle correction uses only total pills input.
|
||||
// For blister, keep loose pills separated from sealed blister/partial counts.
|
||||
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
|
||||
const sealedPills = Math.max(0, currentStock - knownLoose);
|
||||
const fullBlisters =
|
||||
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
|
||||
const partialPills =
|
||||
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
|
||||
|
||||
// Pre-fill with current values
|
||||
setEditStockFullBlisters(fullBlisters);
|
||||
setEditStockPartialBlisterPills(partialPills);
|
||||
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
|
||||
setShowEditStockModal(true);
|
||||
window.history.pushState({ modal: "editStock" }, "");
|
||||
}, []);
|
||||
|
||||
const closeEditStockModal = useCallback(() => {
|
||||
if (showEditStockModal) {
|
||||
let popstateHandled = false;
|
||||
const handlePopstate = () => {
|
||||
popstateHandled = true;
|
||||
};
|
||||
window.addEventListener("popstate", handlePopstate, { once: true });
|
||||
window.history.back();
|
||||
|
||||
// Fallback for cases where no history entry exists for edit stock.
|
||||
window.setTimeout(() => {
|
||||
if (!popstateHandled) {
|
||||
window.removeEventListener("popstate", handlePopstate);
|
||||
setShowEditStockModal(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
}, [showEditStockModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditStockModal) {
|
||||
setEditStockMedication(null);
|
||||
}
|
||||
}, [showEditStockModal]);
|
||||
|
||||
@@ -239,7 +288,10 @@ export function useRefill(): UseRefillReturn {
|
||||
setEditStockFullBlisters,
|
||||
editStockPartialBlisterPills,
|
||||
setEditStockPartialBlisterPills,
|
||||
editStockLoosePills,
|
||||
setEditStockLoosePills,
|
||||
editStockSaving,
|
||||
editStockMedication,
|
||||
loadRefillHistory,
|
||||
submitRefill,
|
||||
submitStockCorrection,
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface Settings {
|
||||
shoutrrrPrescriptionReminders: boolean;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
shareStockStatus: boolean;
|
||||
upcomingTodayOnly: boolean;
|
||||
shareScheduleTodayOnly: boolean;
|
||||
swapDashboardMainSections: boolean;
|
||||
expiryWarningDays: number;
|
||||
}
|
||||
|
||||
@@ -90,6 +93,9 @@ const defaultSettings: Settings = {
|
||||
shoutrrrPrescriptionReminders: true,
|
||||
stockCalculationMode: "automatic",
|
||||
shareStockStatus: true,
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
expiryWarningDays: 30,
|
||||
};
|
||||
|
||||
@@ -224,6 +230,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
|
||||
stockCalculationMode: settingsToSave.stockCalculationMode,
|
||||
shareStockStatus: settingsToSave.shareStockStatus,
|
||||
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
|
||||
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
|
||||
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
|
||||
language: i18n.language,
|
||||
smtpHost: settingsToSave.smtpHost,
|
||||
smtpPort: settingsToSave.smtpPort,
|
||||
@@ -240,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => null);
|
||||
|
||||
const updatedSettings = {
|
||||
...settingsToSave,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
||||
};
|
||||
const updatedSettings = { ...settingsToSave };
|
||||
setSettings(updatedSettings);
|
||||
setSettingsSaving(false);
|
||||
setSavedSettings(updatedSettings);
|
||||
|
||||
+123
-15
@@ -76,7 +76,7 @@
|
||||
"emptyStock_other": "{{count}} Medikamente leer",
|
||||
"lowWarning": "{{count}} Medikament kritisch niedrig",
|
||||
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
|
||||
"waitingFirstCheck": "Warte auf erste Prüfung",
|
||||
"waitingFirstCheck": "Warte auf die erste Prüfung",
|
||||
"type": "Typ",
|
||||
"typeStock": "Bestand",
|
||||
"typeIntake": "Einnahme",
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medikamentenliste",
|
||||
"entries": "{{count}} Einträge",
|
||||
"entries_one": "{{count}} Eintrag",
|
||||
"entries_other": "{{count}} Einträge"
|
||||
"entries_other": "{{count}} Einträge",
|
||||
"markObsolete": "Als obsolet markieren",
|
||||
"reactivate": "Reaktivieren",
|
||||
"obsoleteTitle": "Obsolet ({{count}})",
|
||||
"obsoleteSince": "Beendet",
|
||||
"started": "Gestartet"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packungen",
|
||||
@@ -140,16 +145,24 @@
|
||||
"deleteModal": {
|
||||
"title": "Medikament löschen",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Medikament als obsolet markieren",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich als obsolet markieren?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"editEntry": "Bearbeiten",
|
||||
"editEntryWithName": "Bearbeiten: {{name}}",
|
||||
"viewEntry": "Ansehen",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"sections": {
|
||||
"general": "Allgemein",
|
||||
"stock": "Bestand & Dosis",
|
||||
"prescription": "Rezept"
|
||||
"stock": "Package",
|
||||
"prescription": "Rezept",
|
||||
"prescriptionAndRefill": "Rezept & Nachfüllen",
|
||||
"schedule": "Einnahme"
|
||||
},
|
||||
"commercialName": "Handelsname",
|
||||
"genericName": "Wirkstoff",
|
||||
@@ -165,6 +178,7 @@
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"medicationStartDate": "Startdatum der Medikation",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
@@ -177,6 +191,9 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Einnahmeplan",
|
||||
"remind": "Erinnern",
|
||||
@@ -226,7 +243,7 @@
|
||||
"stockReminders": "Bestands-Erinnerungen",
|
||||
"intakeReminders": "Einnahme-Erinnerungen",
|
||||
"prescriptionReminders": "Rezept-Erinnerungen",
|
||||
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"enableHint": "Aktiviere mindestens einen Kanal, um Benachrichtigungen zu erhalten.",
|
||||
"skipTakenDoses": "Keine Erinnerungen für genommene Dosen",
|
||||
"skipTakenDosesTooltip": "Sende keine Einnahme-Erinnerungen für Dosen, die heute bereits als genommen markiert wurden",
|
||||
"repeatReminders": "Wiederholte Erinnerungen für verpasste Dosen",
|
||||
@@ -265,7 +282,7 @@
|
||||
"automatic": "Automatisch",
|
||||
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
|
||||
"manual": "Manuell",
|
||||
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden",
|
||||
"manualDesc": "Bestand wird nur reduziert, wenn Dosen als genommen markiert werden",
|
||||
"thresholds": "Schwellenwerte",
|
||||
"criticalStockDays": "Kritisch (Tage)",
|
||||
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
|
||||
@@ -273,10 +290,22 @@
|
||||
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
|
||||
"highStockDays": "Hoch (Tage)",
|
||||
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
|
||||
"thresholdValidation": "Werte müssen sein: Kritisch < Niedrig < Hoch",
|
||||
"thresholdValidation": "Werte müssen wie folgt sein: Kritisch < Niedrig < Hoch",
|
||||
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
|
||||
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Allgemeine UI",
|
||||
"upcomingSection": "Bevorstehender Zeitplan",
|
||||
"upcomingTodayOnly": "Nur heute anzeigen",
|
||||
"upcomingTodayOnlyDesc": "Vergangene und zukünftige Tage ausblenden und im Dashboard nur den heutigen Zeitplan anzeigen.",
|
||||
"dashboardSectionOrder": "Dashboard-Layout",
|
||||
"swapDashboardSections": "Bevorstehenden Zeitplan vor Medikamentenübersicht anzeigen",
|
||||
"swapDashboardSectionsDesc": "Wenn aktiviert, wird der Bereich mit bevorstehenden Einnahmen über der Medikamentenübersicht angezeigt.",
|
||||
"sharedSection": "Geteilter Zeitplan",
|
||||
"shareScheduleTodayOnly": "Geteilte Links zeigen nur heute",
|
||||
"shareScheduleTodayOnlyDesc": "Vergangene und zukünftige Tage in geteilten Zeitplänen ausblenden und nur heutige Einträge zeigen."
|
||||
},
|
||||
"stockReminder": {
|
||||
"title": "Bestands-Erinnerung",
|
||||
"description": "Bestands-Erinnerungen aktivieren",
|
||||
@@ -335,7 +364,8 @@
|
||||
},
|
||||
"dose": {
|
||||
"takenBy": "eingenommen von",
|
||||
"markAsTaken": "Als eingenommen markieren"
|
||||
"markAsTaken": "Als eingenommen markieren",
|
||||
"take": "Nehmen"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -363,7 +393,7 @@
|
||||
"checkEmail": "E-Mail überprüfen",
|
||||
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
|
||||
"passwordReset": "Passwort zurückgesetzt",
|
||||
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
|
||||
"passwordResetSuccess": "Dein Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
|
||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||
"rememberMe": "Angemeldet bleiben",
|
||||
"localAccount": "Lokales Konto",
|
||||
@@ -400,12 +430,13 @@
|
||||
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
|
||||
"tooLong": "{{current}}/{{max}} Zeichen"
|
||||
},
|
||||
"saved": "Gespeichert ✓",
|
||||
"saved": "Gespeichert",
|
||||
"save": "Speichern",
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"delete": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"reset": "Zurücksetzen",
|
||||
@@ -419,6 +450,8 @@
|
||||
"of": "von",
|
||||
"loose": "lose",
|
||||
"none": "Kein",
|
||||
"daily": "täglich",
|
||||
"everyNDays": "alle {{count}} Tage",
|
||||
"day": "Tag",
|
||||
"days": "Tage",
|
||||
"blister": "Blister",
|
||||
@@ -430,7 +463,9 @@
|
||||
"pillsTotal": "{{count}} Tabletten gesamt",
|
||||
"pillsTotal_one": "{{count}} Tablette gesamt",
|
||||
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
||||
"max": "max"
|
||||
"max": "max",
|
||||
"on": "An",
|
||||
"off": "Aus"
|
||||
},
|
||||
"share": {
|
||||
"button": "Teilen",
|
||||
@@ -459,7 +494,7 @@
|
||||
}
|
||||
},
|
||||
"exportImport": {
|
||||
"title": "Daten Export / Import",
|
||||
"title": "Datenexport / -import",
|
||||
"description": "Sichere deine Daten oder übertrage sie auf ein anderes Gerät.",
|
||||
"exportTitle": "Export",
|
||||
"exportDesc": "Lade alle deine Daten als JSON-Datei herunter.",
|
||||
@@ -483,10 +518,13 @@
|
||||
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
||||
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||
"confirmButton": "Ja, alles ersetzen",
|
||||
"confirmImportEmpty": "Daten importieren?",
|
||||
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
|
||||
"confirmButtonEmpty": "Importieren",
|
||||
"cancelButton": "Abbrechen",
|
||||
"exportSuccess": "Daten erfolgreich exportiert",
|
||||
"importSuccess": "Daten erfolgreich importiert",
|
||||
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
|
||||
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
|
||||
"importError": "Daten konnten nicht importiert werden",
|
||||
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
|
||||
"downloadFilename": "medassist-export"
|
||||
@@ -513,18 +551,26 @@
|
||||
"prescription": {
|
||||
"enabled": "Rezept verfolgen",
|
||||
"authorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Rezept-Nachfüllungen",
|
||||
"lowThreshold": "Schwelle für Rezept-Erinnerung",
|
||||
"expiryDate": "Rezeptablauf",
|
||||
"useForRefill": "Rezept-Nachfüllung verwenden"
|
||||
},
|
||||
"editStock": {
|
||||
"title": "Bestand korrigieren",
|
||||
"buttonLabel": "Bestand/Angebrochene Blister korrigieren",
|
||||
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
||||
"totalPills": "Gesamte Tabletten",
|
||||
"fullBlisters": "Volle Blister",
|
||||
"partialBlisterPills": "Angebrochener Blister",
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
||||
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
||||
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
||||
"decreaseValue": "Wert verringern",
|
||||
"increaseValue": "Wert erhöhen",
|
||||
"currentTotal": "Aktueller Bestand",
|
||||
"newTotal": "Neuer Bestand",
|
||||
"difference": "Differenz",
|
||||
@@ -549,5 +595,67 @@
|
||||
"copyright": "© {{year}} Daniel Volz",
|
||||
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
||||
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
||||
},
|
||||
"report": {
|
||||
"button": "Bericht",
|
||||
"title": "Medikamentenbericht",
|
||||
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
|
||||
"selectAll": "Alle auswählen",
|
||||
"deselectAll": "Alle abwählen",
|
||||
"activeMeds": "Aktive Medikamente",
|
||||
"obsoleteMeds": "Obsolete Medikamente",
|
||||
"format": "Format",
|
||||
"formatTxt": "Klartext (.txt)",
|
||||
"formatMd": "Markdown (.md)",
|
||||
"formatPdf": "PDF (Drucken)",
|
||||
"generate": "Erstellen",
|
||||
"generating": "Wird erstellt...",
|
||||
"noSelection": "Wähle mindestens ein Medikament aus",
|
||||
"filterByPerson": "Bericht für",
|
||||
"allPeople": "Alle Personen",
|
||||
"docTitle": "Medikamentenbericht",
|
||||
"docGenerated": "Erstellt am",
|
||||
"docGeneral": "Allgemein",
|
||||
"docCommercialName": "Handelsname",
|
||||
"docGenericName": "Wirkstoff",
|
||||
"docTakenBy": "Eingenommen von",
|
||||
"docStartDate": "Startdatum",
|
||||
"docObsoleteSince": "Obsolet seit",
|
||||
"docStatus": "Status",
|
||||
"docStatusActive": "Aktiv",
|
||||
"docStatusObsolete": "Obsolet",
|
||||
"docPackage": "Verpackung",
|
||||
"docPackageType": "Verpackungsart",
|
||||
"docBlister": "Blisterpackung",
|
||||
"docBottle": "Pillendose",
|
||||
"docPacks": "Packungen",
|
||||
"docBlistersPerPack": "Blister pro Packung",
|
||||
"docPillsPerBlister": "Tabletten pro Blister",
|
||||
"docTotalCapacity": "Gesamtkapazität",
|
||||
"docCurrentStock": "Aktueller Bestand",
|
||||
"docLoosePills": "Lose Tabletten",
|
||||
"docDose": "Dosis",
|
||||
"docDosePerPill": "Dosis pro Tablette",
|
||||
"docExpiryDate": "Ablaufdatum",
|
||||
"docNotes": "Notizen",
|
||||
"docIntakeSchedule": "Einnahmeplan",
|
||||
"docIntakeEntry": "{{usage}} Tablette(n) alle {{every}} Tag(e) ab {{start}}",
|
||||
"docIntakeTakenBy": "eingenommen von {{person}}",
|
||||
"docIntakeReminder": "Erinnerung aktiv",
|
||||
"docPrescription": "Rezept",
|
||||
"docAuthorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"docRemainingRefills": "Verbleibende Nachfüllungen",
|
||||
"docPrescriptionExpiry": "Rezeptablauf",
|
||||
"docIntakeHistory": "Einnahme-Verlauf",
|
||||
"docDosesTaken": "Eingenommene Dosen",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
+125
-17
@@ -70,10 +70,10 @@
|
||||
"inDays_one": "in {{days}} day",
|
||||
"inDays_other": "in {{days}} days",
|
||||
"noRemindersNeeded": "No reminders needed",
|
||||
"needRefill": "{{count}} med needs refill",
|
||||
"needRefill_other": "{{count}} meds need refill",
|
||||
"emptyStock": "{{count}} med is empty",
|
||||
"emptyStock_other": "{{count}} meds are empty",
|
||||
"needRefill": "{{count}} medication needs refill",
|
||||
"needRefill_other": "{{count}} medications need refill",
|
||||
"emptyStock": "{{count}} medication is empty",
|
||||
"emptyStock_other": "{{count}} medications are empty",
|
||||
"lowWarning": "{{count}} medication running critically low",
|
||||
"lowWarning_other": "{{count}} medications running critically low",
|
||||
"waitingFirstCheck": "Waiting for first check",
|
||||
@@ -84,10 +84,10 @@
|
||||
"channelEmail": "Email",
|
||||
"channelPush": "Push",
|
||||
"channelBoth": "Email + Push",
|
||||
"criticalMeds": "{{count}} medication critical",
|
||||
"criticalMeds_other": "{{count}} medications critical",
|
||||
"lowMeds": "{{count}} medication low",
|
||||
"lowMeds_other": "{{count}} medications low",
|
||||
"criticalMeds": "{{count}} medication is critical",
|
||||
"criticalMeds_other": "{{count}} medications are critical",
|
||||
"lowMeds": "{{count}} medication is low",
|
||||
"lowMeds_other": "{{count}} medications are low",
|
||||
"prescriptionNeeds": "Prescription low",
|
||||
"prescriptionLowMeds": "{{count}} prescription low",
|
||||
"prescriptionLowMeds_other": "{{count}} prescriptions low",
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medication list",
|
||||
"entries": "{{count}} entries",
|
||||
"entries_one": "{{count}} entry",
|
||||
"entries_other": "{{count}} entries"
|
||||
"entries_other": "{{count}} entries",
|
||||
"markObsolete": "Mark obsolete",
|
||||
"reactivate": "Reactivate",
|
||||
"obsoleteTitle": "Obsolete ({{count}})",
|
||||
"obsoleteSince": "Stopped",
|
||||
"started": "Started"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packs",
|
||||
@@ -140,16 +145,24 @@
|
||||
"deleteModal": {
|
||||
"title": "Delete medication",
|
||||
"message": "Do you really want to delete \"{{name}}\"?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Mark medication as obsolete",
|
||||
"message": "Do you really want to mark \"{{name}}\" as obsolete?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit medication",
|
||||
"editEntry": "Edit",
|
||||
"editEntryWithName": "Edit: {{name}}",
|
||||
"viewEntry": "View",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"stock": "Stock & Dose",
|
||||
"prescription": "Prescription"
|
||||
"stock": "Package",
|
||||
"prescription": "Prescription",
|
||||
"prescriptionAndRefill": "Rx & Refill",
|
||||
"schedule": "Schedule"
|
||||
},
|
||||
"commercialName": "Commercial Name",
|
||||
"genericName": "Generic Name",
|
||||
@@ -165,6 +178,7 @@
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"medicationStartDate": "Medication Start Date",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
@@ -177,6 +191,9 @@
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Intake schedule",
|
||||
"remind": "Remind",
|
||||
@@ -277,6 +294,18 @@
|
||||
"shareStockStatus": "Show Stock on Shared Links",
|
||||
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "General UI",
|
||||
"upcomingSection": "Upcoming Schedule",
|
||||
"upcomingTodayOnly": "Show only today",
|
||||
"upcomingTodayOnlyDesc": "Hide past and future days and show only today's schedule on the dashboard.",
|
||||
"dashboardSectionOrder": "Dashboard Layout",
|
||||
"swapDashboardSections": "Show Upcoming Schedules before Medication Overview",
|
||||
"swapDashboardSectionsDesc": "When enabled, the dashboard prioritizes the upcoming schedule section above the medication overview section.",
|
||||
"sharedSection": "Shared Schedule",
|
||||
"shareScheduleTodayOnly": "Shared links show only today",
|
||||
"shareScheduleTodayOnlyDesc": "Hide past and future days on shared schedule links and show only today's entries."
|
||||
},
|
||||
"stockReminder": {
|
||||
"title": "Stock Reminder",
|
||||
"description": "Enable stock reminders",
|
||||
@@ -335,7 +364,8 @@
|
||||
},
|
||||
"dose": {
|
||||
"takenBy": "taken by",
|
||||
"markAsTaken": "Mark as taken"
|
||||
"markAsTaken": "Mark as taken",
|
||||
"take": "Take"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -400,12 +430,13 @@
|
||||
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
|
||||
"tooLong": "{{current}}/{{max}} characters"
|
||||
},
|
||||
"saved": "Saved ✓",
|
||||
"saved": "Saved",
|
||||
"save": "Save",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"reset": "Reset",
|
||||
@@ -419,6 +450,8 @@
|
||||
"of": "of",
|
||||
"loose": "loose",
|
||||
"none": "None",
|
||||
"daily": "daily",
|
||||
"everyNDays": "every {{count}} days",
|
||||
"day": "day",
|
||||
"days": "days",
|
||||
"blister": "blister",
|
||||
@@ -430,7 +463,9 @@
|
||||
"pillsTotal": "{{count}} pills total",
|
||||
"pillsTotal_one": "{{count}} pill total",
|
||||
"pillsTotal_other": "{{count}} pills total",
|
||||
"max": "max"
|
||||
"max": "max",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"share": {
|
||||
"button": "Share",
|
||||
@@ -483,10 +518,13 @@
|
||||
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
||||
"confirmImportWarning": "This action cannot be undone!",
|
||||
"confirmButton": "Yes, Replace All",
|
||||
"confirmImportEmpty": "Import Data?",
|
||||
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
|
||||
"confirmButtonEmpty": "Import",
|
||||
"cancelButton": "Cancel",
|
||||
"exportSuccess": "Data exported successfully",
|
||||
"importSuccess": "Data imported successfully",
|
||||
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
|
||||
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
|
||||
"importError": "Failed to import data",
|
||||
"invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
|
||||
"downloadFilename": "medassist-export"
|
||||
@@ -513,18 +551,26 @@
|
||||
"prescription": {
|
||||
"enabled": "Track prescription",
|
||||
"authorizedRefills": "Authorized refills",
|
||||
"remainingRefills": "Remaining refills",
|
||||
"remainingRefills": "Remaining prescription refills",
|
||||
"lowThreshold": "Low-refill reminder threshold",
|
||||
"expiryDate": "Prescription expiry",
|
||||
"useForRefill": "Use prescription refill"
|
||||
},
|
||||
"editStock": {
|
||||
"title": "Correct Stock",
|
||||
"buttonLabel": "Correct Stock/Partial Blister",
|
||||
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
||||
"totalPills": "Total pills",
|
||||
"fullBlisters": "Full blisters",
|
||||
"partialBlisterPills": "Partial blister",
|
||||
"loosePills": "Loose pills",
|
||||
"pillsPerBlister": "({{count}} pills each)",
|
||||
"packageSize": "Package size: {{count}} pills",
|
||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} pills Pack = {{total}} pills",
|
||||
"currentComposition": "Current stock: {{fullBlisters}} full blisters + {{partialPills}} partial + {{loosePills}} loose = {{total}} pills",
|
||||
"maxExceeded": "Maximum package size is {{count}} pills. Values were capped.",
|
||||
"decreaseValue": "Decrease value",
|
||||
"increaseValue": "Increase value",
|
||||
"currentTotal": "Current total",
|
||||
"newTotal": "New total",
|
||||
"difference": "Difference",
|
||||
@@ -549,5 +595,67 @@
|
||||
"copyright": "© {{year}} Daniel Volz",
|
||||
"madeWith": "Made with ❤️ for better health management",
|
||||
"techStack": "Built with React, Fastify & SQLite"
|
||||
},
|
||||
"report": {
|
||||
"button": "Report",
|
||||
"title": "Medication Report",
|
||||
"description": "Generate a document with detailed medication information for your doctor or personal records.",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"activeMeds": "Active Medications",
|
||||
"obsoleteMeds": "Obsolete Medications",
|
||||
"format": "Format",
|
||||
"formatTxt": "Plain Text (.txt)",
|
||||
"formatMd": "Markdown (.md)",
|
||||
"formatPdf": "PDF (Print)",
|
||||
"generate": "Generate",
|
||||
"generating": "Generating...",
|
||||
"noSelection": "Select at least one medication",
|
||||
"filterByPerson": "Report for",
|
||||
"allPeople": "Everyone",
|
||||
"docTitle": "Medication Report",
|
||||
"docGenerated": "Generated on",
|
||||
"docGeneral": "General",
|
||||
"docCommercialName": "Commercial Name",
|
||||
"docGenericName": "Generic Name",
|
||||
"docTakenBy": "Taken by",
|
||||
"docStartDate": "Start Date",
|
||||
"docObsoleteSince": "Obsolete Since",
|
||||
"docStatus": "Status",
|
||||
"docStatusActive": "Active",
|
||||
"docStatusObsolete": "Obsolete",
|
||||
"docPackage": "Package",
|
||||
"docPackageType": "Package Type",
|
||||
"docBlister": "Blister Pack",
|
||||
"docBottle": "Pill Bottle",
|
||||
"docPacks": "Packs",
|
||||
"docBlistersPerPack": "Blisters per pack",
|
||||
"docPillsPerBlister": "Pills per blister",
|
||||
"docTotalCapacity": "Total capacity",
|
||||
"docCurrentStock": "Current stock",
|
||||
"docLoosePills": "Loose pills",
|
||||
"docDose": "Dose",
|
||||
"docDosePerPill": "Dose per pill",
|
||||
"docExpiryDate": "Expiry Date",
|
||||
"docNotes": "Notes",
|
||||
"docIntakeSchedule": "Intake Schedule",
|
||||
"docIntakeEntry": "{{usage}} pill(s) every {{every}} day(s) from {{start}}",
|
||||
"docIntakeTakenBy": "taken by {{person}}",
|
||||
"docIntakeReminder": "reminder enabled",
|
||||
"docPrescription": "Prescription",
|
||||
"docAuthorizedRefills": "Authorized refills",
|
||||
"docRemainingRefills": "Remaining refills",
|
||||
"docPrescriptionExpiry": "Prescription expiry",
|
||||
"docIntakeHistory": "Intake History",
|
||||
"docDosesTaken": "Doses taken",
|
||||
"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 App from "./App";
|
||||
import "./styles.css";
|
||||
import "./styles/modals-base.css";
|
||||
import "./styles/share-dialog.css";
|
||||
import "./styles/medication-workflows.css";
|
||||
import "./styles/schedule-mobile-edit.css";
|
||||
import "./i18n";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1031
-700
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { PlannerRow } from "../types";
|
||||
@@ -158,8 +158,7 @@ export function PlannerPage() {
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t("planner.from")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
step="60"
|
||||
value={range.start}
|
||||
onChange={(e) => setRange({ ...range, start: e.target.value })}
|
||||
@@ -167,24 +166,21 @@ export function PlannerPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("planner.until")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.end}
|
||||
onChange={(e) => setRange({ ...range, end: e.target.value })}
|
||||
/>
|
||||
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<label className="planner-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeUntilStart}
|
||||
onChange={(e) => setIncludeUntilStart(e.target.checked)}
|
||||
/>
|
||||
{t("planner.includeUntilStart")}
|
||||
<div className="planner-checkbox-row">
|
||||
<label className="planner-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeUntilStart}
|
||||
onChange={(e) => setIncludeUntilStart(e.target.checked)}
|
||||
/>
|
||||
{t("planner.includeUntilStart")}
|
||||
</label>
|
||||
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="planner-actions">
|
||||
<button type="button" className="ghost" onClick={resetRange}>
|
||||
{t("common.reset")}
|
||||
@@ -210,14 +206,25 @@ export function PlannerPage() {
|
||||
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
|
||||
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
||||
return (
|
||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<div
|
||||
key={row.medicationId}
|
||||
className="table-row clickable"
|
||||
onClick={() => med && openMedDetail(med)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med) openMedDetail(med);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
|
||||
{row.medicationName}
|
||||
</span>
|
||||
<span data-label={t("planner.table.usage")}>
|
||||
<strong>{row.plannerUsage}</strong>
|
||||
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
||||
<span>
|
||||
<strong>{row.plannerUsage}</strong>
|
||||
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
</span>
|
||||
<span data-label={t("planner.table.blisters")}>
|
||||
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
@@ -129,7 +130,7 @@ export function SchedulePage() {
|
||||
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
const isCollapsed = !isManuallyExpanded;
|
||||
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||
const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -139,6 +140,9 @@ export function SchedulePage() {
|
||||
<div
|
||||
className="day-divider clickable"
|
||||
onClick={() => toggleDayCollapse(day.dateStr, true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true);
|
||||
}}
|
||||
title={isCollapsed ? t("common.expand") : t("common.collapse")}
|
||||
>
|
||||
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
|
||||
@@ -189,15 +193,19 @@ export function SchedulePage() {
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>{" "}
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}{" "}
|
||||
<div className="dose-checks">
|
||||
@@ -210,6 +218,9 @@ export function SchedulePage() {
|
||||
<span
|
||||
className="person-name clickable"
|
||||
onClick={() => openUserFilter(person)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
</span>
|
||||
@@ -229,7 +240,8 @@ export function SchedulePage() {
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -264,6 +276,19 @@ export function SchedulePage() {
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
const wasCollapsed = !showPastDays;
|
||||
setShowPastDays(!showPastDays);
|
||||
if (wasCollapsed) {
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector(".day-block.today")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
@@ -329,15 +354,19 @@ export function SchedulePage() {
|
||||
<div key={dose.id} className="dose-item">
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
|
||||
<span className="dose-usage-main">
|
||||
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
</span>
|
||||
{med?.pillWeightMg && (
|
||||
<span className="dose-usage-weight">{`${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"}`}</span>
|
||||
)}
|
||||
</span>
|
||||
{dose.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<div className="dose-checks">
|
||||
@@ -351,7 +380,13 @@ export function SchedulePage() {
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{person && (
|
||||
<span className="person-name clickable" onClick={() => openUserFilter(person)}>
|
||||
<span
|
||||
className="person-name clickable"
|
||||
onClick={() => openUserFilter(person)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
@@ -370,7 +405,8 @@ export function SchedulePage() {
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
>
|
||||
✓
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+162
-103
@@ -30,8 +30,10 @@ export function SettingsPage() {
|
||||
handleImportConfirm,
|
||||
importResult,
|
||||
setImportResult,
|
||||
meds,
|
||||
} = useAppContext();
|
||||
|
||||
const hasExistingData = meds.length > 0;
|
||||
return (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
@@ -43,28 +45,26 @@ export function SettingsPage() {
|
||||
<div className="card-head">
|
||||
<h2>{t("settings.language.title")}</h2>
|
||||
</div>
|
||||
<div className="setting-section">
|
||||
<label className="setting-row language-row">
|
||||
<span className="setting-label">{t("settings.language.select")}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
i18n.changeLanguage(lang);
|
||||
fetch("/api/settings/language", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
className="language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="setting-row language-row">
|
||||
<span className="setting-label">{t("settings.language.select")}</span>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
i18n.changeLanguage(lang);
|
||||
fetch("/api/settings/language", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
className="language-select"
|
||||
>
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
</select>
|
||||
</label>
|
||||
</article>
|
||||
|
||||
{/* Notifications */}
|
||||
@@ -89,7 +89,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
||||
checked={settings.emailStockReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -100,9 +100,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
||||
}
|
||||
checked={settings.shoutrrrStockReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -116,7 +114,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
||||
checked={settings.emailIntakeReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -127,9 +125,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
||||
}
|
||||
checked={settings.shoutrrrIntakeReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -143,9 +139,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
|
||||
}
|
||||
checked={settings.emailPrescriptionReminders}
|
||||
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.emailEnabled}
|
||||
/>
|
||||
@@ -156,11 +150,7 @@ export function SettingsPage() {
|
||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.shoutrrrUrl && settings.shoutrrrEnabled
|
||||
? settings.shoutrrrPrescriptionReminders
|
||||
: false
|
||||
}
|
||||
checked={settings.shoutrrrPrescriptionReminders}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
||||
disabled={!settings.shoutrrrEnabled}
|
||||
/>
|
||||
@@ -373,25 +363,25 @@ export function SettingsPage() {
|
||||
{settings.emailEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t("settings.email.recipient")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<div className="full">
|
||||
<span className="field-label">
|
||||
{t("settings.email.recipient")}
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={`SMTP: ${settings.smtpHost || t("settings.email.notConfigured")}:${settings.smtpPort}${settings.hasSmtpPassword ? "\nPassword: ✓" : ""}`}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.notificationEmail}
|
||||
onChange={(e) => setSettings({ ...settings, notificationEmail: e.target.value })}
|
||||
placeholder="your@email.com"
|
||||
pattern="[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button
|
||||
@@ -442,23 +432,23 @@ export function SettingsPage() {
|
||||
{settings.shoutrrrEnabled && (
|
||||
<>
|
||||
<div className="setting-group">
|
||||
<label className="full">
|
||||
<span className="field-label">{t("settings.push.url")}</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder={t("settings.push.urlPlaceholder")}
|
||||
/>
|
||||
<div className="full">
|
||||
<span className="field-label">
|
||||
{t("settings.push.url")}
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={`${t("settings.push.supports")}\n\n${t("settings.push.docsLink")}`}
|
||||
>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.shoutrrrUrl}
|
||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||
placeholder={t("settings.push.urlPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button
|
||||
@@ -606,7 +596,7 @@ export function SettingsPage() {
|
||||
<h3>{t("settings.stock.thresholds")}</h3>
|
||||
</div>
|
||||
<div className="setting-group threshold-chips-group">
|
||||
<label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||
<div className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||
<span className="field-label threshold-chip-label">
|
||||
<span className="status-chip small danger">{t("status.criticalStock")}</span>
|
||||
<span
|
||||
@@ -616,17 +606,15 @@ export function SettingsPage() {
|
||||
ⓘ
|
||||
</span>
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.reminderDaysBefore}
|
||||
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
settings.lowStockDays <= settings.reminderDaysBefore ||
|
||||
settings.lowStockDays >= settings.highStockDays
|
||||
@@ -643,17 +631,15 @@ export function SettingsPage() {
|
||||
ⓘ
|
||||
</span>
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.lowStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
</div>
|
||||
<div className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
|
||||
<span className="field-label threshold-chip-label">
|
||||
<span className="status-chip small high">{t("status.highStock")}</span>
|
||||
<span
|
||||
@@ -663,24 +649,76 @@ export function SettingsPage() {
|
||||
ⓘ
|
||||
</span>
|
||||
</span>
|
||||
<div className="input-with-tooltip">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={settings.highStockDays}
|
||||
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(settings.reminderDaysBefore >= settings.lowStockDays ||
|
||||
settings.lowStockDays >= settings.highStockDays) && (
|
||||
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* General UI */}
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2>{t("settings.timeline.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t("settings.timeline.dashboardSectionOrder")}</h3>
|
||||
</div>
|
||||
<div className="setting-row compact">
|
||||
<div className="setting-label">
|
||||
<span>{t("settings.timeline.swapDashboardSections")}</span>
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.timeline.swapDashboardSectionsDesc")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.swapDashboardMainSections}
|
||||
onChange={(e) => setSettings({ ...settings, swapDashboardMainSections: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t("settings.timeline.upcomingSection")}</h3>
|
||||
</div>
|
||||
<div className="setting-row compact">
|
||||
<div className="setting-label">
|
||||
<span>{t("settings.timeline.upcomingTodayOnly")}</span>
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.timeline.upcomingTodayOnlyDesc")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.upcomingTodayOnly}
|
||||
onChange={(e) => setSettings({ ...settings, upcomingTodayOnly: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<div className="section-header">
|
||||
<h3>{t("settings.timeline.sharedSection")}</h3>
|
||||
</div>
|
||||
<div className="setting-row compact">
|
||||
<div className="setting-label">
|
||||
<span>{t("settings.stock.shareStockStatus")}</span>
|
||||
@@ -697,6 +735,22 @@ export function SettingsPage() {
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-row compact" style={{ marginTop: "10px" }}>
|
||||
<div className="setting-label">
|
||||
<span>{t("settings.timeline.shareScheduleTodayOnly")}</span>
|
||||
<span className="info-tooltip small" data-tooltip={t("settings.timeline.shareScheduleTodayOnlyDesc")}>
|
||||
ⓘ
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.shareScheduleTodayOnly}
|
||||
onChange={(e) => setSettings({ ...settings, shareScheduleTodayOnly: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -734,6 +788,7 @@ export function SettingsPage() {
|
||||
{t("exportImport.importSuccessDetails", {
|
||||
medications: importResult.medications,
|
||||
doses: importResult.doses,
|
||||
refills: importResult.refills,
|
||||
shares: importResult.shares,
|
||||
})}
|
||||
</span>
|
||||
@@ -741,6 +796,7 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportResult(null)}
|
||||
aria-label={t("common.close")}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -751,7 +807,6 @@ export function SettingsPage() {
|
||||
color: "inherit",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -806,21 +861,25 @@ export function SettingsPage() {
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("exportImport.confirmImport")}
|
||||
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
|
||||
message={
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||
</>
|
||||
hasExistingData ? (
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
|
||||
)
|
||||
}
|
||||
confirmLabel={t("exportImport.confirmButton")}
|
||||
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
|
||||
cancelLabel={t("exportImport.cancelButton")}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={() => {
|
||||
setShowImportConfirm(false);
|
||||
setPendingImportData(null);
|
||||
}}
|
||||
confirmVariant="danger"
|
||||
confirmVariant={hasExistingData ? "danger" : "primary"}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+1339
-990
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
/* =============================================================================
|
||||
Refill Modal & History
|
||||
============================================================================= */
|
||||
.refill-modal {
|
||||
max-width: 500px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-modal h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.refill-med-name {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.refill-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.refill-form input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.refill-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-footer-right .refill-preview {
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
/* Refill modal footer mobile */
|
||||
@media (max-width: 640px) {
|
||||
.refill-modal .modal-footer {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer > button,
|
||||
.refill-modal .modal-footer .refill-footer-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer .refill-footer-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.refill-modal .modal-footer .refill-footer-right button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Refill: submit row (button + pill preview) */
|
||||
.refill-submit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refill-submit-row button {
|
||||
height: 42px;
|
||||
padding: 0 2rem;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Refill: prescription toggle row */
|
||||
.refill-prescription-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.refill-prescription-row .refill-prescription-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.2;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: normal;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refill-prescription-row .refill-prescription-toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.refill-prescription-label-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.refill-prescription-row .refill-prescription-toggle input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refill-remaining-badge {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--success);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.refill-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
padding: 0 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--success);
|
||||
border-radius: 6px;
|
||||
color: var(--success);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.refill-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.refill-section .refill-submit-row button {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.refill-section .refill-submit-row button:hover:not(:disabled) {
|
||||
background: var(--success-hover, #3aa865);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.refill-section .refill-submit-row button:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refill-unavailable {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--border-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Edit Stock Modal (Correction)
|
||||
============================================================================= */
|
||||
.edit-stock-modal {
|
||||
max-width: 500px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.edit-stock-modal h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.edit-stock-med-name {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-stock-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-bg);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(252, 211, 77, 0.2);
|
||||
}
|
||||
|
||||
.edit-stock-cap-info {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.edit-stock-live-breakdown {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.edit-stock-cap-warning {
|
||||
margin: -0.25rem 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid rgba(252, 211, 77, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.edit-stock-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.edit-stock-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-stock-form label .hint-text {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.edit-stock-form input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edit-stock-form input[type="number"]::-webkit-outer-spin-button,
|
||||
.edit-stock-form input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.edit-stock-form input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.number-stepper input {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.number-stepper input[type="number"]::-webkit-outer-spin-button,
|
||||
.number-stepper input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.number-stepper input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.number-stepper input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
color: var(--text-secondary);
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.stepper-btn svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepper-btn.increment {
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stepper-btn.decrement {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.stepper-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.stepper-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-theme="light"] .stepper-btn.increment {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
[data-theme="light"] .stepper-btn.decrement {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.number-stepper {
|
||||
grid-template-columns: 3rem minmax(0, 1fr) 3rem;
|
||||
}
|
||||
|
||||
.number-stepper input {
|
||||
font-size: 1.2rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
min-width: 3rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.number-stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.number-stepper input {
|
||||
order: 1;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
|
||||
.number-stepper .stepper-btn {
|
||||
flex: 0 0 2.9rem;
|
||||
min-width: 2.9rem;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.number-stepper .stepper-btn.decrement {
|
||||
order: 2;
|
||||
background: color-mix(in srgb, var(--danger) 74%, var(--bg-secondary));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.number-stepper .stepper-btn.increment {
|
||||
order: 3;
|
||||
background: color-mix(in srgb, var(--success) 72%, var(--bg-secondary));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.number-stepper .stepper-btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
[data-theme="light"] .number-stepper .stepper-btn.decrement {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="light"] .number-stepper .stepper-btn.increment {
|
||||
background: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep refill modal stepper on legacy visual style (before desktop clustered correction style). */
|
||||
.refill-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.refill-number-stepper input {
|
||||
order: initial;
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn {
|
||||
flex: 0 0 auto;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
border-left: none;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.decrement {
|
||||
order: initial;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn.increment {
|
||||
order: initial;
|
||||
border-right: none;
|
||||
border-left: 1px solid var(--border-primary);
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
|
||||
filter: none;
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.refill-number-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
|
||||
}
|
||||
|
||||
.refill-number-stepper input {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
|
||||
color: #0f766e;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.refill-number-stepper {
|
||||
grid-template-columns: 3rem minmax(0, 1fr) 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-stock-summary {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row span:first-child {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row span:last-child {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row.difference {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row.difference.positive span:last-child {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.edit-stock-summary .summary-row.difference.negative span:last-child {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Clickable section header (for expand/collapse) */
|
||||
.section-header-clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section-header-clickable:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Refill history in detail modal */
|
||||
.refill-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.refill-history-header .collapse-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.refill-history-header .refill-count {
|
||||
font-weight: normal;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.refill-history-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.refill-history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.refill-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.refill-date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.refill-details {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Nested modal overlay */
|
||||
.modal-overlay.nested {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-overlay.nested-confirm {
|
||||
z-index: 1200;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/* =============================================================================
|
||||
Modal Base Styles
|
||||
============================================================================= */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem 1rem;
|
||||
animation: fadeIn 0.2s ease;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.3s ease;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 450px);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.modal-content.confirm-modal {
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
max-height: min(85dvh, 85vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close,
|
||||
.lightbox-close {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--btn-ghost-hover);
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--btn-radius-round);
|
||||
transition:
|
||||
background 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-footer .footer-left,
|
||||
.modal-footer .footer-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-footer button.icon-only {
|
||||
padding: 0.5rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user