Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf18318410 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c | |||
| e2ed25059a | |||
| 7554a79898 | |||
| 70f2392a71 | |||
| ba789f9794 | |||
| 277fc3e686 | |||
| b838f0e8ea | |||
| 0b888cf00a | |||
| dbc722a898 | |||
| 15a44d4f55 | |||
| 4de138015d | |||
| 3bb8b93a4c | |||
| 3af8a5a704 | |||
| f301f24182 | |||
| 6dc1e68392 | |||
| e4b1630922 | |||
| c7be73786b | |||
| cdfb19bde2 | |||
| f7da65e7a1 | |||
| 27e42c0935 | |||
| 67ad693b31 | |||
| ab3facc47a | |||
| ce02b4211a | |||
| 40bd7ba3b7 | |||
| 826d85937c | |||
| 6d98a049bc | |||
| 435ca5f1d6 | |||
| ecf9cfb539 | |||
| dafa5abab4 | |||
| cc5141c997 | |||
| 22725fa566 | |||
| a5fe76545e | |||
| 527f4251e5 | |||
| 5064de3bff | |||
| 40d6f33676 | |||
| 0dab318b66 | |||
| 932524125e | |||
| c291c88f2b | |||
| e42e4f5639 | |||
| b70fc88921 | |||
| 95aec8350a | |||
| 401228699f | |||
| 0d2b21199e |
+9
-2
@@ -13,6 +13,12 @@ PORT=3000
|
|||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=warn
|
LOG_LEVEL=warn
|
||||||
|
|
||||||
|
# Public base URL used for notification action links.
|
||||||
|
# Required for intake reminder action buttons/links.
|
||||||
|
# PUBLIC_APP_URL=https://medassist.example.com
|
||||||
|
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
|
||||||
|
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
@@ -37,7 +43,8 @@ LOG_LEVEL=warn
|
|||||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||||
# OPENAPI_DOCS_ENABLED=true
|
# OPENAPI_DOCS_ENABLED=true
|
||||||
|
|
||||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
|
||||||
|
# Users can override this per account in Settings -> Timezone.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -148,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# UI defaults
|
# UI defaults
|
||||||
# DEFAULT_LANGUAGE=en # en or de
|
# DEFAULT_LANGUAGE=en # en or de
|
||||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||||
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
|
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
|
||||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT
|
## VERY IMPORTANT - Prioritized Constraints
|
||||||
|
|
||||||
|
**First: Update Memory and Reports**
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- If `doku/memory_notes.md` is missing, create it immediately.
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- If `doku/report.md` is missing, create it immediately.
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
|
**Second: Follow Governance Rules**
|
||||||
|
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Read Dependabot metadata
|
- name: Read Dependabot metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: dependabot/fetch-metadata@v2
|
uses: dependabot/fetch-metadata@v3
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.current_tag.outputs.value }}
|
tag_name: ${{ steps.current_tag.outputs.value }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
|
||||||
steps:
|
steps:
|
||||||
- name: Move project item to Done
|
- name: Move project item to Done
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Sync fields
|
- name: Sync fields
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Build weekly summary
|
- name: Build weekly summary
|
||||||
id: summary
|
id: summary
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
core.setOutput('body', body);
|
core.setOutput('body', body);
|
||||||
|
|
||||||
- name: Publish report issue
|
- name: Publish report issue
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
|||||||
+25
-1
@@ -83,14 +83,18 @@ Thumbs.db
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
docs/TECH_STACK.md
|
docs/TECH_STACK.md
|
||||||
doku/
|
doku/
|
||||||
|
|
||||||
|
# Local agent work logs stay on disk but must never go upstream.
|
||||||
doku/memory_notes.md
|
doku/memory_notes.md
|
||||||
doku/report.md
|
doku/report.md
|
||||||
plan/
|
plan/
|
||||||
.copilot-tracking/
|
.copilot-tracking/
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
.agents/
|
||||||
|
skills-lock.json
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Local Spec Kit artifacts
|
# Local Spec Kit workspace state
|
||||||
# ===================
|
# ===================
|
||||||
.specify/
|
.specify/
|
||||||
specs/
|
specs/
|
||||||
@@ -98,3 +102,23 @@ docs/SPEC_KIT.md
|
|||||||
.github/agents/medassist-feature-orchestrator.agent.md
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
.github/agents/speckit.*.agent.md
|
.github/agents/speckit.*.agent.md
|
||||||
.github/prompts/speckit.*.prompt.md
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/accessibility/
|
||||||
|
.github/skills/frontend-design/
|
||||||
|
.github/skills/nodejs-backend-patterns/
|
||||||
|
.github/skills/nodejs-best-practices/
|
||||||
|
.github/skills/seo/
|
||||||
|
.playwright-mcp
|
||||||
|
|
||||||
|
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||||
|
.github/agents/copilot-instructions.md
|
||||||
|
.github/agents/gsd-*.agent.md
|
||||||
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
|
.github/agents/speckit.*.agent.md
|
||||||
|
.github/get-shit-done/
|
||||||
|
.github/gsd-file-manifest.json
|
||||||
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/gsd-*/
|
||||||
|
.planning/
|
||||||
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
ops/medtest/
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- refreshed: 2026-04-30 -->
|
||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend SPA (React) │
|
||||||
|
├──────────────────┬──────────────────┬───────────────────────┤
|
||||||
|
│ App Shell/Routes │ Shared State │ Feature Pages │
|
||||||
|
│ `frontend/src/ │ `frontend/src/ │ `frontend/src/pages/` │
|
||||||
|
│ App.tsx` │ context/` │ │
|
||||||
|
└────────┬─────────┴────────┬─────────┴──────────┬────────────┘
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend API (Fastify) │
|
||||||
|
│ `backend/src/index.ts` + `backend/src/routes/` │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ SQLite Persistence + Migration Layer │
|
||||||
|
│ `backend/src/db/schema.ts` + `backend/src/db/client.ts` │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
| Component | Responsibility | File |
|
||||||
|
|-----------|----------------|------|
|
||||||
|
| Frontend bootstrap | Mount providers/router and start app tree | `frontend/src/main.tsx` |
|
||||||
|
| App router/shell | Public share routes, authenticated shell routes, global modal composition | `frontend/src/App.tsx` |
|
||||||
|
| Frontend orchestration | Compose domain hooks and expose app-level state/actions | `frontend/src/context/AppContext.tsx` |
|
||||||
|
| API proxy boundary | Rewrite `/api/*` requests to backend root routes | `frontend/vite.config.ts` |
|
||||||
|
| Backend composition root | Register plugins/routes, await migrations, start schedulers | `backend/src/index.ts` |
|
||||||
|
| Route handlers | HTTP contracts, validation, auth hooks, response shaping | `backend/src/routes/*.ts` |
|
||||||
|
| Domain services | Shared domain logic and scheduler behavior | `backend/src/services/*.ts` |
|
||||||
|
| Persistence | Table definitions + compatibility migration/runtime initialization | `backend/src/db/schema.ts`, `backend/src/db/client.ts` |
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
**Overall:** Layered modular monolith (single frontend SPA + single backend process)
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Frontend uses React Router + context/hook composition (`frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`).
|
||||||
|
- Backend uses route modules with shared service modules (`backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||||
|
- Data persistence is centralized in Drizzle schema + startup migrations (`backend/src/db/schema.ts`, `backend/src/db/client.ts`).
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**Frontend Presentation + Orchestration:**
|
||||||
|
- Purpose: Render UI, route navigation, manage client state, invoke API.
|
||||||
|
- Location: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/pages/`, `frontend/src/context/`, `frontend/src/hooks/`.
|
||||||
|
- Contains: pages, modals, app shell, hook-based API callers.
|
||||||
|
- Depends on: backend `/api/*`, i18n, shared frontend utils/types.
|
||||||
|
- Used by: browser clients.
|
||||||
|
|
||||||
|
**Backend HTTP/API Layer:**
|
||||||
|
- Purpose: Expose REST endpoints, authenticate/authorize requests, validate input, map to service/db logic.
|
||||||
|
- Location: `backend/src/index.ts`, `backend/src/routes/`, `backend/src/plugins/`.
|
||||||
|
- Contains: Fastify app setup, route registration, auth middleware.
|
||||||
|
- Depends on: services, db client/schema, env plugin.
|
||||||
|
- Used by: frontend SPA and API consumers.
|
||||||
|
|
||||||
|
**Domain Services Layer:**
|
||||||
|
- Purpose: Reusable business logic for scheduling, notifications, stock math, parsing.
|
||||||
|
- Location: `backend/src/services/`, `backend/src/utils/`.
|
||||||
|
- Contains: reminder scheduler, notification builders/delivery, medication helpers.
|
||||||
|
- Depends on: db models and utilities.
|
||||||
|
- Used by: routes and startup process.
|
||||||
|
|
||||||
|
**Persistence Layer:**
|
||||||
|
- Purpose: Define DB schema and keep existing SQLite instances compatible.
|
||||||
|
- Location: `backend/src/db/schema.ts`, `backend/src/db/client.ts`, `backend/drizzle/`.
|
||||||
|
- Contains: tables, migration execution, backward-compatible alter migrations.
|
||||||
|
- Depends on: Drizzle + libsql client.
|
||||||
|
- Used by: routes/services.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Primary Request Path
|
||||||
|
|
||||||
|
1. Frontend page triggers API call via `/api/*` fetch (`frontend/src/pages/PlannerPage.tsx:307`).
|
||||||
|
2. Vite proxy rewrites `/api` prefix to backend route root (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||||
|
3. Fastify route handles request under `/planner/send-email` with auth + validation (`backend/src/routes/planner.ts:141`, `backend/src/routes/planner.ts:158`).
|
||||||
|
4. Route loads user settings and dispatches channel delivery helpers (`backend/src/routes/planner.ts:221`, `backend/src/routes/planner.ts:432`, `backend/src/routes/planner.ts:829`).
|
||||||
|
|
||||||
|
### Public Share Flow
|
||||||
|
|
||||||
|
1. Frontend routes public token URL to shared schedule view (`frontend/src/App.tsx:35`).
|
||||||
|
2. Shared schedule component fetches token payload from `/api/share/:token` (`frontend/src/components/SharedSchedule.tsx:311`).
|
||||||
|
3. Backend public share route reads token/settings and returns filtered medication schedule (`backend/src/routes/share.ts:125`, `backend/src/routes/share.ts:156`).
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- Frontend: context-centric state aggregation (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||||
|
- Backend: DB-backed state with runtime scheduler state persisted through notification state utilities (`backend/src/services/reminder-scheduler.ts:42`).
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**Auth Context + Guards:**
|
||||||
|
- Purpose: unify session/API-key auth across protected routes.
|
||||||
|
- Examples: `backend/src/plugins/auth.ts`, `backend/src/routes/settings.ts`.
|
||||||
|
- Pattern: route-level `preHandler` guard plus request decoration (`backend/src/routes/settings.ts:138`, `backend/src/plugins/auth.ts:236`).
|
||||||
|
|
||||||
|
**Notification Delivery Contract:**
|
||||||
|
- Purpose: keep route-triggered and scheduler-triggered notifications consistent.
|
||||||
|
- Examples: `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/notifications/delivery.ts`.
|
||||||
|
- Pattern: shared builders/delivery/state helpers imported into both paths (`backend/src/routes/planner.ts:23`, `backend/src/services/reminder-scheduler.ts:39`).
|
||||||
|
|
||||||
|
**Frontend App Context Aggregator:**
|
||||||
|
- Purpose: centralize shared medication/settings/dose/share/refill state for page/modal consumers.
|
||||||
|
- Examples: `frontend/src/context/AppContext.tsx`, `frontend/src/context/ShareContext.tsx`.
|
||||||
|
- Pattern: compose domain hooks, expose typed value via provider (`frontend/src/context/AppContext.tsx:248`, `frontend/src/context/AppContext.tsx:1020`).
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Frontend bootstrap:**
|
||||||
|
- Location: `frontend/src/main.tsx`
|
||||||
|
- Triggers: browser loads `index.html`.
|
||||||
|
- Responsibilities: initialize theme/provider stack and router (`frontend/src/main.tsx:12`, `frontend/src/main.tsx:15`).
|
||||||
|
|
||||||
|
**Backend process entry:**
|
||||||
|
- Location: `backend/src/index.ts`
|
||||||
|
- Triggers: `npm run dev`/`npm start` in backend package.
|
||||||
|
- Responsibilities: await migrations, register routes, start HTTP listener and schedulers (`backend/src/index.ts:231`, `backend/src/index.ts:305`, `backend/src/index.ts:309`, `backend/src/index.ts:334`).
|
||||||
|
|
||||||
|
## Architectural Constraints
|
||||||
|
|
||||||
|
- **Threading:** Single Node.js event loop process with in-process schedulers started at runtime (`backend/src/index.ts:309`, `backend/src/index.ts:323`).
|
||||||
|
- **Global state:** Module/global singletons exist in auth and context layers (`backend/src/plugins/auth.ts:15`, `frontend/src/context/AppContext.tsx:222`).
|
||||||
|
- **Circular imports:** Not detected from sampled route/service/db/frontend orchestration files.
|
||||||
|
- **API boundary:** Frontend network calls must use `/api/*` so proxy rewrite applies (`frontend/vite.config.ts:23`, `frontend/vite.config.ts:26`).
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
### Duplicated Backend App Wiring
|
||||||
|
|
||||||
|
**What happens:** Route/plugin registration appears in both `createApp(...)` and top-level startup path.
|
||||||
|
**Why it's wrong:** Two bootstrap paths increase divergence risk when new routes/plugins are added in one path but not the other.
|
||||||
|
**Do this instead:** Keep a single shared app-construction function used by both test/runtime startup paths (`backend/src/index.ts:133`, `backend/src/index.ts:207`, `backend/src/index.ts:289`).
|
||||||
|
|
||||||
|
### Oversized Frontend Orchestration Context
|
||||||
|
|
||||||
|
**What happens:** `AppContext` aggregates many unrelated concerns (medications, settings, doses, sharing, import/export, modal history) in one large provider.
|
||||||
|
**Why it's wrong:** High coupling and broad rerender surface make safe changes harder and increase regression risk.
|
||||||
|
**Do this instead:** Preserve existing provider contract, but move new domain concerns into focused hooks/providers and re-export through composition only when needed (`frontend/src/context/AppContext.tsx`, file size ~1035 lines).
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Fail fast at route boundary with explicit status codes and schema validation, then log context-rich errors.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Route validation + immediate 400 responses for invalid input (`backend/src/routes/medications.ts:76`, `backend/src/routes/medications.ts:584`).
|
||||||
|
- Planner routes return explicit channel/config errors (`backend/src/routes/planner.ts:204`, `backend/src/routes/planner.ts:509`).
|
||||||
|
- Frontend captures network errors and maps them to normalized error codes for UI handling (`frontend/src/hooks/useMedications.ts:80`).
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Logging:** Fastify logger options configured centrally with environment-aware formatting (`backend/src/index.ts:66`, `backend/src/index.ts:161`).
|
||||||
|
**Validation:** Zod validation for medication payloads and explicit OpenAPI schema contracts in routes (`backend/src/routes/medications.ts:76`, `backend/src/routes/planner.ts:157`).
|
||||||
|
**Authentication:** Route-level auth hooks and dual API-key/session handling (`backend/src/routes/planner.ts:141`, `backend/src/plugins/auth.ts:113`, `backend/src/plugins/auth.ts:236`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Codebase Concerns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
**Backend startup duplication and config drift:**
|
||||||
|
- Issue: `backend/src/index.ts` contains two parallel server setup paths (the exported `createApp(...)` flow and the top-level runtime bootstrap). Plugin/route registration and rate-limit defaults are duplicated in both branches.
|
||||||
|
- Files: `backend/src/index.ts`
|
||||||
|
- Impact: Configuration behavior can diverge between test/programmatic app construction and production startup (for example, `createApp` uses fixed `rateLimit` max `300`, while runtime startup uses `process.env.RATE_LIMIT_MAX` fallback `100`).
|
||||||
|
- Fix approach: Extract one canonical app-construction function and let both runtime startup and tests consume it; remove duplicated registration blocks.
|
||||||
|
|
||||||
|
**Notification architecture leakage and duplicated composition logic:**
|
||||||
|
- Issue: Notification delivery service code imports a route-layer helper (`sendShoutrrrNotification`) from settings routes, and large HTML/text reminder composition blocks are duplicated across manual and automatic reminder paths.
|
||||||
|
- Files: `backend/src/services/notifications/delivery.ts`, `backend/src/routes/settings.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Impact: Layer boundary violations increase coupling, and duplicated notification formatting logic makes behavior regressions likely when changing message content or channel behavior.
|
||||||
|
- Fix approach: Move `sendShoutrrrNotification` to a service-layer module, make routes call service APIs only, and centralize email/push payload builders for planner + scheduler flows.
|
||||||
|
|
||||||
|
**Migration artifact ambiguity in drizzle numbering:**
|
||||||
|
- Issue: There are two migration files with `0008_` prefix, but the journal tracks only one `0008` tag and then jumps to `0009`.
|
||||||
|
- Files: `backend/drizzle/0008_add_obsolete_medications.sql`, `backend/drizzle/0008_add_prescription_tracking.sql`, `backend/drizzle/meta/_journal.json`
|
||||||
|
- Impact: Developer confusion and higher risk of migration-order mistakes during future schema changes.
|
||||||
|
- Fix approach: Align migration file names and journal tags so each migration number is unique and journal order is obvious.
|
||||||
|
|
||||||
|
**Monolithic UI/editor and route modules with broad lint suppressions:**
|
||||||
|
- Issue: Core interaction files are very large and rely on file-level `biome-ignore-all` suppressions for multiple rule categories.
|
||||||
|
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/SharedSchedule.tsx`, `frontend/src/components/MedDetailModal.tsx`, `backend/src/routes/medications.ts`
|
||||||
|
- Impact: Refactors become high-risk; local regressions are harder to isolate; suppressed rule categories hide legitimate quality issues in future edits.
|
||||||
|
- Fix approach: Split by domain slices (state orchestration vs rendering vs helper transforms), then replace file-level suppressions with narrow, local exceptions only where justified.
|
||||||
|
|
||||||
|
## Known Bugs
|
||||||
|
|
||||||
|
**Environment-dependent behavior mismatch between test app factory and runtime app:**
|
||||||
|
- Symptoms: Programmatic app creation and runtime startup can apply different operational defaults (rate limiting and selected config pathways).
|
||||||
|
- Files: `backend/src/index.ts`
|
||||||
|
- Trigger: Using `createApp(...)` in tests/integration contexts while production startup uses the top-level runtime branch.
|
||||||
|
- Workaround: Explicitly pass runtime-equivalent options into `createApp(...)` in tests until startup construction is unified.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**Server-side outbound notification surface is broad and sensitive to parser correctness:**
|
||||||
|
- Risk: The app performs server-side HTTP requests to user-configurable notification URLs, including multiple protocol handlers (`pushover://`, `telegram://`, `gotify://`, generic webhook URLs).
|
||||||
|
- Files: `backend/src/routes/settings.ts`
|
||||||
|
- Current mitigation: URL sanitation/validation and hostname checks are present (`sanitizeNotificationUrl`, `validateNotificationHostname` usage in route logic).
|
||||||
|
- Recommendations: Add focused security regression tests for sanitizer bypasses and callback URL edge cases, and keep all outbound request execution in a dedicated service layer.
|
||||||
|
|
||||||
|
**Auth-off bootstrap path creates implicit default user state:**
|
||||||
|
- Risk: In auth-disabled mode, startup creates/relies on a default user path automatically.
|
||||||
|
- Files: `backend/src/db/client.ts`
|
||||||
|
- Current mitigation: Controlled by `AUTH_ENABLED` environment setting.
|
||||||
|
- Recommendations: Add startup log warnings when running without auth outside development and enforce explicit environment confirmation in deployment templates.
|
||||||
|
|
||||||
|
## Performance Bottlenecks
|
||||||
|
|
||||||
|
**Reminder scheduling uses repeated full scans over users and medication/dose datasets:**
|
||||||
|
- Problem: Reminder checks iterate all user settings and compute stock/prescription reminders with repeated in-memory loops over medication and dose collections.
|
||||||
|
- Files: `backend/src/services/reminder-scheduler.ts`, `backend/src/utils/scheduler-utils.ts`
|
||||||
|
- Cause: Polling/check strategy prioritizes correctness and compatibility over incremental indexing.
|
||||||
|
- Improvement path: Introduce incremental candidate selection (changed-medication windows, per-user next-check indices) and reduce repeated whole-set scans.
|
||||||
|
|
||||||
|
**Intake reminder scheduler polls every minute and may scale linearly with active schedules:**
|
||||||
|
- Problem: Intake reminder check loop runs continuously at 60s interval and processes all due reminders/users each tick.
|
||||||
|
- Files: `backend/src/services/intake-reminder-scheduler.ts`
|
||||||
|
- Cause: Fixed-interval scheduler (`CHECK_INTERVAL_MS = 60 * 1000`) with loop-driven due-item selection.
|
||||||
|
- Improvement path: Move toward next-due-time scheduling or bucketing strategy; keep minute polling as fallback only.
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
**Reminder state persistence and lock handling mix sync file IO with best-effort catches:**
|
||||||
|
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Why fragile: Reminder state writes are synchronous file writes and some read paths swallow errors (`catch {}`), while lock/state files are local filesystem coordination primitives.
|
||||||
|
- Safe modification: Keep file format backward-compatible, add explicit error telemetry, and add tests for concurrent/failed write scenarios before changing scheduler state logic.
|
||||||
|
- Test coverage: No direct tests detected for `notifications/delivery.ts` and only limited direct state-function assertions.
|
||||||
|
|
||||||
|
**Desktop/mobile medication edit parity depends on two large independent UI paths:**
|
||||||
|
- Files: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MobileEditModal.tsx`, `frontend/src/components/medications/MedicationEditCoordinator.tsx`
|
||||||
|
- Why fragile: The same editing domain is implemented in separate surfaces, each with dense UI logic and custom interaction handling.
|
||||||
|
- Safe modification: Apply shared form-section components first, then update desktop and mobile in the same change; validate both paths with targeted tests.
|
||||||
|
- Test coverage: Coverage exists (`MedicationEditCoordinator`, `MobileEditModal`, `MedicationDialogs` tests), but parity regressions remain a recurring risk due to file size/complexity.
|
||||||
|
|
||||||
|
## Scaling Limits
|
||||||
|
|
||||||
|
**Current reminder architecture is single-node/local-state oriented:**
|
||||||
|
- Current capacity: Scheduler state and lock coordination are local files under data directory (`reminder-state.json`, `scheduler-locks/*`).
|
||||||
|
- Limit: Horizontal multi-instance scaling can duplicate work or require externalized coordination.
|
||||||
|
- Scaling path: Move reminder state/locks to DB or distributed lock backend and make scheduler execution leader-aware.
|
||||||
|
|
||||||
|
**SQLite file-backed persistence constrains concurrent write scaling:**
|
||||||
|
- Current capacity: Single SQLite file with local filesystem path resolution.
|
||||||
|
- Limit: Higher write concurrency and distributed deployments will hit filesystem/database locking and throughput limits.
|
||||||
|
- Scaling path: Keep SQLite for local/small deployments; define migration path to managed DB for larger multi-user workloads.
|
||||||
|
|
||||||
|
## Dependencies at Risk
|
||||||
|
|
||||||
|
**Route-to-service coupling in notification stack:**
|
||||||
|
- Risk: Service-layer delivery module depends on route-layer helper import.
|
||||||
|
- Impact: Refactors of route modules can break unrelated notification infrastructure and complicate testing boundaries.
|
||||||
|
- Migration plan: Move shared notification send helpers into `backend/src/services/notifications/*` and keep route modules thin.
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
**Risk-driven scheduler stress/integration test suite for state-lock edge cases:**
|
||||||
|
- Problem: Complex scheduler/state code paths rely on file coordination and mixed channel delivery outcomes, but dedicated stress/chaos-style verification is limited.
|
||||||
|
- Blocks: High-confidence scaling and reliability changes in reminder subsystems.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**Notification delivery abstraction lacks direct unit tests:**
|
||||||
|
- What's not tested: Direct behavior of SMTP transport creation/result validation and push delivery helpers in the dedicated delivery module.
|
||||||
|
- Files: `backend/src/services/notifications/delivery.ts`
|
||||||
|
- Risk: Regressions in recipient validation, SMTP response handling, or provider fallback can ship unnoticed.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
**Reminder state persistence/locking has limited direct verification:**
|
||||||
|
- What's not tested: Corrupted file recovery, concurrent state writes, and lock stale-file behavior under failure modes.
|
||||||
|
- Files: `backend/src/services/notifications/state.ts`, `backend/src/services/reminder-scheduler.ts`
|
||||||
|
- Risk: Duplicate sends or missed sends after crashes/restarts are difficult to detect early.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Concerns audit: 2026-04-30*
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Frontend React components and pages use PascalCase file names (for example `frontend/src/components/MobileEditModal.tsx`, `frontend/src/pages/MedicationsPage.tsx`).
|
||||||
|
- Hooks use `useX` camelCase naming in files and symbols (for example `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useScheduleController.ts`).
|
||||||
|
- Backend routes/services use kebab-case file names with domain suffixes (for example `backend/src/routes/medications.ts`, `backend/src/services/medications-service.ts`).
|
||||||
|
- Test files use `*.test.ts` or `*.test.tsx` in dedicated test folders (for example `backend/src/test/planner.test.ts`, `frontend/src/test/components/MobileEditModal.test.tsx`).
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- Use camelCase names for functions and methods (for example `parseIntakesWithUnits` in `backend/src/services/medications-service.ts`, `loadMeds` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
- Use verb-first names for side-effect operations (`loadMeds`, `deleteMed`, `uploadMedImage` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- Use camelCase for local variables and state (`refillHistoryExpanded`, `scheduleDays`, `showFutureDays` in `frontend/src/context/AppContext.tsx`).
|
||||||
|
- Constant maps and singleton keys use UPPER_SNAKE_CASE (`LOG_LEVELS` in `backend/src/utils/logger.ts`, `APP_CONTEXT_SINGLETON_KEY` in `frontend/src/context/AppContext.tsx`).
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- Type aliases and interfaces use PascalCase (`AppContextValue` in `frontend/src/context/AppContext.tsx`, `TestContext` in `backend/src/test/setup.ts`).
|
||||||
|
- Return-shape interfaces use `UseXReturn` convention for hooks (`UseMedicationsReturn` in `frontend/src/hooks/useMedications.ts`).
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- Tool used: Biome (`biome.json`, scripts in `frontend/package.json`, `backend/package.json`, `package.json`).
|
||||||
|
- Key settings from `biome.json`:
|
||||||
|
- `indentStyle: tab`
|
||||||
|
- `indentWidth: 2`
|
||||||
|
- `lineWidth: 120`
|
||||||
|
- JavaScript quote style is double quotes, semicolons enabled, trailing commas `es5`.
|
||||||
|
|
||||||
|
**Linting:**
|
||||||
|
- Tool used: Biome linter (`biome.json`).
|
||||||
|
- Key rules enforced/relevant:
|
||||||
|
- `style.useConst: error`
|
||||||
|
- `style.noNestedTernary: warn`
|
||||||
|
- `correctness.noUnusedVariables: warn`
|
||||||
|
- `suspicious.noExplicitAny: warn`
|
||||||
|
- Project governance in `AGENTS.md` reinforces readable code, early returns, and no nested ternaries.
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
**Order:**
|
||||||
|
1. Node built-ins first in backend modules (for example `node:path` in `backend/src/routes/medications.ts`, `node:crypto` in `backend/src/index.ts`).
|
||||||
|
2. External packages second (`fastify`, `zod`, `drizzle-orm` in backend; `react`, `@testing-library/*` in frontend).
|
||||||
|
3. Internal modules last with relative paths (`../db/client.js`, `../../types`).
|
||||||
|
|
||||||
|
**Path Aliases:**
|
||||||
|
- Not detected in TypeScript configs (`frontend/tsconfig.json`, `backend/tsconfig.json` do not define `paths`).
|
||||||
|
- Relative imports are the standard.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Backend validates request data with Zod schemas and `.refine(...)` constraints before route logic (`backend/src/routes/medications.ts`).
|
||||||
|
- Backend route tests assert explicit status codes and body shape (`backend/src/test/routes-real.test.ts`, `backend/src/test/planner.test.ts`).
|
||||||
|
- Frontend hooks often normalize recoverable API errors into UI-safe states (`frontend/src/hooks/useMedications.ts` converts network failures into `NETWORK_ERROR`).
|
||||||
|
- Some frontend fetch flows still use tolerant fallbacks (`catch(() => setMeds([]))` in `frontend/src/hooks/useMedications.ts`), so future changes should prefer explicit user-facing error channels per `AGENTS.md` fail-clear guidance.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- Backend startup logger wrapper over console with level filtering in `backend/src/utils/logger.ts`.
|
||||||
|
- Runtime HTTP logging via Fastify logger options in `backend/src/index.ts` (`buildLoggerOptions`, request correlation IDs).
|
||||||
|
- Frontend logging utility mirrors backend level semantics (`frontend/src/utils/logger.ts`).
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Central log-level maps (`LOG_LEVELS`) and `shouldLog` gating are standard in both frontend and backend logger modules.
|
||||||
|
- Correlation ID propagation is enforced at request boundaries (`backend/src/index.ts` onRequest hook setting `x-correlation-id`).
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
**When to Comment:**
|
||||||
|
- Comments are used for rationale and test setup intent, not line-by-line narration.
|
||||||
|
- Typical examples:
|
||||||
|
- Migration/setup intent in `backend/src/test/setup.ts`
|
||||||
|
- E2E stability rationale in `frontend/e2e/fixtures/index.ts`
|
||||||
|
- Timeout/determinism notes in `frontend/vitest.config.ts` and `frontend/playwright.base.config.ts`
|
||||||
|
|
||||||
|
**JSDoc/TSDoc:**
|
||||||
|
- Used selectively for exported utilities and test helpers (`backend/src/test/setup.ts`, `frontend/e2e/fixtures/index.ts`, `frontend/src/utils/logger.ts`).
|
||||||
|
- Not mandatory for every function; concise type annotations plus targeted comments are preferred.
|
||||||
|
|
||||||
|
## Function Design
|
||||||
|
|
||||||
|
**Size:**
|
||||||
|
- Small-to-medium focused functions are common in services/hooks (`parseRawIntakeUnits`, `normalizeDateTime` in `backend/src/services/medications-service.ts`).
|
||||||
|
- Larger orchestrator modules exist where domain aggregation is required (`frontend/src/context/AppContext.tsx`).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- Object parameters are used for extensibility in test factories and route payload shapes (`CreateMedicationOptions` in `backend/src/test/setup.ts`).
|
||||||
|
- Explicit primitive parameters used for concise helpers (`clickEditMed(page, medName)` in `frontend/e2e/medication-edit.spec.ts`).
|
||||||
|
|
||||||
|
**Return Values:**
|
||||||
|
- Explicit return types are common on exported functions (`Promise<TestContext>`, `UseMedicationsReturn`).
|
||||||
|
- Guard-clause returns are common for invalid input or unavailable state (`if (!intakesJson) return [];` in `backend/src/services/medications-service.ts`).
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- Named exports are preferred for utilities, hooks, and service functions (`backend/src/services/notifications/index.ts`, `frontend/src/hooks/index.ts`).
|
||||||
|
- Mixed export style is used where legacy/default exports remain practical (`default` exports in component barrel `frontend/src/components/index.ts`).
|
||||||
|
|
||||||
|
**Barrel Files:**
|
||||||
|
- Barrel files are actively used for stable import surfaces:
|
||||||
|
- `frontend/src/components/index.ts`
|
||||||
|
- `frontend/src/hooks/index.ts`
|
||||||
|
- `backend/src/services/notifications/index.ts`
|
||||||
|
- Practical rule for new code: export domain-level public APIs through local barrels, keep deep internal helpers imported directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## APIs & External Services
|
||||||
|
|
||||||
|
**Medication Data APIs:**
|
||||||
|
- European Medicines Agency (EMA) JSON catalog - medication lookup seed and periodic catalog refresh
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`EMA_MEDICINES_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
- RxNorm (NLM RxNav REST) - normalized name/search enrichment and strength/form hints
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`RXNORM_BASE_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
- openFDA NDC API - product/package metadata enrichment
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/services/medication-enrichment.ts` (`OPENFDA_NDC_URL`)
|
||||||
|
- Auth: none detected in code
|
||||||
|
|
||||||
|
**Authentication/Identity Provider Integration:**
|
||||||
|
- OIDC providers (Authelia, Authentik, Pocket ID, Keycloak documented) - SSO login/callback flow
|
||||||
|
- SDK/Client: `openid-client` used in `backend/src/routes/oidc.ts`
|
||||||
|
- Auth: `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` validated in `backend/src/plugins/env.ts`
|
||||||
|
|
||||||
|
**Messaging/Notifications:**
|
||||||
|
- SMTP providers - transactional reminder/test emails
|
||||||
|
- SDK/Client: `nodemailer` in `backend/src/services/notifications/delivery.ts`
|
||||||
|
- Auth: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` or `SMTP_TOKEN`, `SMTP_FROM`, `SMTP_SECURE`
|
||||||
|
- Push endpoints via Shoutrrr-compatible URL parsing
|
||||||
|
- SDK/Client: native `fetch` in `backend/src/routes/settings.ts` (`sendShoutrrrNotification`)
|
||||||
|
- Auth: URL-embedded creds/token per provider and optional basic auth extracted/sanitized in code
|
||||||
|
- Explicit external push provider endpoints used directly:
|
||||||
|
- `https://api.pushover.net/1/messages.json` in `backend/src/routes/settings.ts`
|
||||||
|
- `https://api.telegram.org` in `backend/src/routes/settings.ts`
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
- SQLite (file-based, local persistent volume)
|
||||||
|
- Connection: `DATA_DIR` (path resolution), optional `DOTENV_PATH` for env source
|
||||||
|
- Client: `@libsql/client` + `drizzle-orm` in `backend/src/db/client.ts`
|
||||||
|
- Migration pipeline:
|
||||||
|
- SQL migration artifacts in `backend/drizzle/*.sql`
|
||||||
|
- Runtime migration/alter execution in `backend/src/db/client.ts` and `backend/src/db/migration-utils.ts`
|
||||||
|
|
||||||
|
**File Storage:**
|
||||||
|
- Local filesystem only
|
||||||
|
- Backend data root resolved by `backend/src/db/path-utils.ts`
|
||||||
|
- Image/static user files served from `/images` in `backend/src/index.ts`
|
||||||
|
- Compose bind mount `./data:/app/data` in `docker-compose.yml`
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- In-process memory cache only for selected integration data
|
||||||
|
- OIDC discovery config cache in `backend/src/routes/oidc.ts` (`oidcConfig`)
|
||||||
|
- EMA catalog snapshot + refresh promise in `backend/src/services/medication-enrichment.ts`
|
||||||
|
- No external cache service detected (no Redis/Memcached dependency in package manifests)
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Auth Provider:**
|
||||||
|
- Custom session/JWT auth with optional OIDC SSO extension
|
||||||
|
- Implementation: Fastify cookie + JWT plugin, refresh token table, API key hashing in `backend/src/plugins/auth.ts`, `backend/src/routes/auth.ts`, `backend/src/plugins/jwt.ts`, `backend/src/routes/oidc.ts`
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Error Tracking:**
|
||||||
|
- None detected for third-party SaaS error tracking (no Sentry/Rollbar/etc. dependencies)
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
- Structured app logging via Fastify/Pino in `backend/src/index.ts`
|
||||||
|
- Pretty logging in dev through `pino-pretty` (`backend/package.json`, logger setup in `backend/src/index.ts`)
|
||||||
|
- Frontend/nginx log behavior controlled through env and `frontend/nginx-entrypoint.sh` (documented in `.env.example`)
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:**
|
||||||
|
- Container image publishing to GitHub Container Registry (`ghcr.io`) in `.github/workflows/docker-build.yml`
|
||||||
|
- Runtime deployment model is self-hosted Docker Compose stack (`docker-compose.yml`)
|
||||||
|
|
||||||
|
**CI Pipeline:**
|
||||||
|
- GitHub Actions for lint/type/test (`.github/workflows/test.yml`)
|
||||||
|
- Playwright E2E job (`.github/workflows/e2e.yml`)
|
||||||
|
- Docker build/push and optional release automation (`.github/workflows/docker-build.yml`)
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required env vars:**
|
||||||
|
- Core runtime: `PORT`, `CORS_ORIGINS`, `LOG_LEVEL`, `TZ` (`backend/src/plugins/env.ts`, `.env.example`)
|
||||||
|
- Auth when enabled: `AUTH_ENABLED=true` with `JWT_SECRET`, `REFRESH_SECRET`, `COOKIE_SECRET` (`backend/src/plugins/env.ts`)
|
||||||
|
- OIDC when enabled: `OIDC_ENABLED=true` with issuer/client/redirect vars (`backend/src/plugins/env.ts`)
|
||||||
|
- Email notifications: `SMTP_HOST`, `SMTP_USER`, plus pass/token and sender config (`backend/src/services/notifications/delivery.ts`, `.env.example`)
|
||||||
|
- Data location: `DATA_DIR` used by DB path resolver (`backend/src/db/path-utils.ts`)
|
||||||
|
|
||||||
|
**Secrets location:**
|
||||||
|
- Local runtime env file `.env` (present in repository root; values not inspected)
|
||||||
|
- CI secrets managed by GitHub Actions secret store (e.g., `${{ secrets.GITHUB_TOKEN }}` in `.github/workflows/docker-build.yml`)
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming:**
|
||||||
|
- OIDC callback endpoint: `/auth/oidc/callback` in `backend/src/routes/oidc.ts`
|
||||||
|
- No inbound third-party webhook receiver route detected in backend routes
|
||||||
|
|
||||||
|
**Outgoing:**
|
||||||
|
- Outbound HTTP notifications to webhook-style targets from `sendShoutrrrNotification` in `backend/src/routes/settings.ts`
|
||||||
|
- Provider-specific outgoing callbacks/APIs:
|
||||||
|
- Pushover API endpoint
|
||||||
|
- Telegram Bot API endpoint
|
||||||
|
- Outbound SMTP delivery through configured mail host (`backend/src/services/notifications/delivery.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-04-30*
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
**Primary:**
|
||||||
|
- TypeScript (ESM) - Backend and frontend application code in `backend/src/**/*.ts` and `frontend/src/**/*.{ts,tsx}`
|
||||||
|
- SQL (SQLite migrations) - Schema evolution files in `backend/drizzle/*.sql`
|
||||||
|
|
||||||
|
**Secondary:**
|
||||||
|
- CSS - UI styling in `frontend/src/**/*.css` and CSS modules such as `frontend/src/features/schedule/TimelineSurface.module.css`
|
||||||
|
- YAML - CI/CD and compose configuration in `.github/workflows/*.yml`, `docker-compose.yml`, `docker-compose.dev.yml`
|
||||||
|
- Shell - Container/runtime entrypoints in `backend/docker-entrypoint.sh`, `frontend/nginx-entrypoint.sh`
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Node.js 22 runtime baseline (`node:22-slim` in `backend/Dockerfile`, `frontend/Dockerfile`; `actions/setup-node@v6` with `node-version: '22'` in `.github/workflows/test.yml` and `.github/workflows/e2e.yml`)
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- npm (scripts in root `package.json`, `backend/package.json`, `frontend/package.json`)
|
||||||
|
- Lockfile: present (`backend/package-lock.json`, `frontend/package-lock.json` referenced by workflow cache in `.github/workflows/test.yml`)
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- Fastify 5 (`fastify`, `@fastify/*` in `backend/package.json`; app bootstrap in `backend/src/index.ts`)
|
||||||
|
- React 19 (`react`, `react-dom` in `frontend/package.json`; app entry in `frontend/src/main.tsx`)
|
||||||
|
- Vite 8 (`vite` and `@vitejs/plugin-react` in `frontend/package.json`; config in `frontend/vite.config.ts`)
|
||||||
|
- Drizzle ORM + libSQL client (`drizzle-orm`, `@libsql/client` in `backend/package.json`; DB init in `backend/src/db/client.ts`)
|
||||||
|
- Mantine 8 UI system (`@mantine/*` in `frontend/package.json`; provider in `frontend/src/ui/providers/AppUiProvider.tsx`)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Vitest 4 (`vitest`, `@vitest/coverage-v8` in backend/frontend package manifests; configs in `backend/vitest.config.ts`, `frontend/vitest.config.ts`)
|
||||||
|
- Playwright (`@playwright/test` in `frontend/package.json`; configs in `frontend/playwright*.config.ts`; CI run in `.github/workflows/e2e.yml`)
|
||||||
|
- Testing Library (`@testing-library/*` in `frontend/package.json`)
|
||||||
|
|
||||||
|
**Build/Dev:**
|
||||||
|
- TypeScript compiler (`tsc` scripts in `backend/package.json` and frontend type-check via `frontend/package.json`)
|
||||||
|
- TSX watcher for backend dev (`tsx watch src/index.ts` in `backend/package.json`)
|
||||||
|
- Biome for lint/format (`biome.json`, lint/check scripts across package manifests)
|
||||||
|
- Drizzle Kit for DB migration generation (`drizzle-kit` in `backend/package.json`, config in `backend/drizzle.config.ts`)
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- `fastify` and `@fastify/*` - HTTP API runtime, security middleware, docs middleware (`backend/src/index.ts`)
|
||||||
|
- `drizzle-orm` + `@libsql/client` - SQLite data access and migration execution (`backend/src/db/client.ts`)
|
||||||
|
- `openid-client` + `jose` - OIDC SSO and token operations (`backend/src/routes/oidc.ts`, `backend/package.json`)
|
||||||
|
- `nodemailer` - SMTP notification delivery (`backend/src/services/notifications/delivery.ts`)
|
||||||
|
- `react`, `react-router-dom`, `@mantine/*` - SPA UI shell, routing, and component system (`frontend/src/main.tsx`, `frontend/src/App.tsx`)
|
||||||
|
- `i18next` + `react-i18next` - Localization runtime (`frontend/src/i18n/index.ts`)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `dotenv` + `zod` - env loading/validation (`backend/src/plugins/env.ts`)
|
||||||
|
- `sharp` - image processing pipeline support (`backend/package.json`, image route usage in medication flows)
|
||||||
|
- `@fastify/swagger` + `@fastify/swagger-ui` - OpenAPI docs on `/docs` (`backend/src/index.ts`)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Runtime env schema and validation in `backend/src/plugins/env.ts`
|
||||||
|
- Example variable inventory in `.env.example`
|
||||||
|
- Frontend proxy target via `BACKEND_URL` in `frontend/vite.config.ts` and compose files
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- Backend TS build config: `backend/tsconfig.json`
|
||||||
|
- Frontend TS + Vite config: `frontend/tsconfig.json`, `frontend/tsconfig.node.json`, `frontend/vite.config.ts`
|
||||||
|
- DB migration tooling config: `backend/drizzle.config.ts`
|
||||||
|
- Quality tooling config: `biome.json`
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Node.js 22 with npm for local runs (`backend/package.json`, `frontend/package.json` scripts)
|
||||||
|
- Optional Docker Compose local stack (`docker-compose.dev.yml`)
|
||||||
|
- Browser runtime for frontend and Playwright browser binaries for E2E (`frontend/package.json`, `.github/workflows/e2e.yml`)
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Containerized deployment using prebuilt images from GHCR (`docker-compose.yml` references `ghcr.io/danielvolz/medassist-ng-backend:latest` and `ghcr.io/danielvolz/medassist-ng-frontend:latest`)
|
||||||
|
- Backend persistent filesystem for SQLite/data in mounted `./data` (`docker-compose.yml`, DB path resolver in `backend/src/db/path-utils.ts`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Codebase Structure
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
medassist/
|
||||||
|
├── frontend/ # React + Vite SPA, UI, hooks, page routes, frontend tests
|
||||||
|
├── backend/ # Fastify API, domain services, DB schema/migrations, backend tests
|
||||||
|
├── backend/drizzle/ # SQL migration files + drizzle meta journal
|
||||||
|
├── docs/ # Product/ops docs and screenshots
|
||||||
|
├── doku/ # Local-only working notes and reports (ignored)
|
||||||
|
├── .github/ # CI workflows, agents, local skill/runtime metadata
|
||||||
|
├── .planning/codebase/ # Generated codebase mapping documents
|
||||||
|
├── data/ # Runtime/local SQLite backups and scheduler files
|
||||||
|
└── package.json # Root workspace scripts for lint orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**frontend/src:**
|
||||||
|
- Purpose: Product UI and client-side app logic.
|
||||||
|
- Contains: `pages/`, `components/`, `context/`, `hooks/`, `ui/`, `utils/`, `i18n/`, `test/`.
|
||||||
|
- Key files: `frontend/src/main.tsx`, `frontend/src/App.tsx`, `frontend/src/context/AppContext.tsx`.
|
||||||
|
|
||||||
|
**backend/src:**
|
||||||
|
- Purpose: HTTP API, auth, domain services, and persistence access.
|
||||||
|
- Contains: `routes/`, `services/`, `plugins/`, `db/`, `utils/`, `test/`.
|
||||||
|
- Key files: `backend/src/index.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/db/client.ts`.
|
||||||
|
|
||||||
|
**backend/drizzle:**
|
||||||
|
- Purpose: SQL migration history for SQLite compatibility.
|
||||||
|
- Contains: numbered migration files and `meta/_journal.json`.
|
||||||
|
- Key files: `backend/drizzle/0000_init.sql`, `backend/drizzle/0014_add_user_settings_timezone.sql`.
|
||||||
|
|
||||||
|
**frontend/e2e:**
|
||||||
|
- Purpose: Playwright end-to-end scenarios and fixtures.
|
||||||
|
- Contains: browser tests + auth fixtures.
|
||||||
|
- Key files: `frontend/e2e/fixtures/` and spec files under `frontend/e2e/`.
|
||||||
|
|
||||||
|
**docs + doku:**
|
||||||
|
- Purpose: formal docs (`docs/`) and local-only work tracking (`doku/`).
|
||||||
|
- Contains: behavior/spec docs, screenshots, local report/memory logs.
|
||||||
|
- Key files: `docs/TECH_STACK.md`, `doku/memory_notes.md`, `doku/report.md`.
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- `frontend/src/main.tsx`: Browser bootstrap; mounts providers and router.
|
||||||
|
- `frontend/src/App.tsx`: Route graph and global modal/shell orchestration.
|
||||||
|
- `backend/src/index.ts`: Fastify app setup + startup runtime.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `frontend/vite.config.ts`: Dev server, `/api` proxy rewrite, build-time constants.
|
||||||
|
- `frontend/vitest.config.ts`: Frontend unit test config.
|
||||||
|
- `backend/vitest.config.ts`: Backend unit/integration test config.
|
||||||
|
- `backend/drizzle.config.ts`: Drizzle migration configuration.
|
||||||
|
- `.gitignore`: Local-only/generated path policy (including `.planning/`, `doku/`, `data/`, coverage/test artifacts).
|
||||||
|
|
||||||
|
**Core Logic:**
|
||||||
|
- `backend/src/routes/`: API contracts and request handlers.
|
||||||
|
- `backend/src/services/`: Scheduler, notifications, medication helpers.
|
||||||
|
- `backend/src/db/schema.ts`: Source-of-truth table definitions.
|
||||||
|
- `frontend/src/context/`: Shared app orchestration state.
|
||||||
|
- `frontend/src/pages/`: Screen-level composition.
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- `frontend/src/test/`: Frontend unit/component tests.
|
||||||
|
- `frontend/e2e/`: Playwright E2E tests.
|
||||||
|
- `backend/src/test/`: Backend route/service/db tests.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- React components/pages use PascalCase: `frontend/src/pages/MedicationsPage.tsx`, `frontend/src/components/MedDetailModal.tsx`.
|
||||||
|
- Hooks use `use*` naming: `frontend/src/hooks/useMedications.ts`, `frontend/src/hooks/useSettings.ts`.
|
||||||
|
- Backend routes/services use kebab-case: `backend/src/routes/medication-enrichment.ts`, `backend/src/services/reminder-scheduler.ts`.
|
||||||
|
- Migrations use numbered descriptive names: `backend/drizzle/0012_add_api_keys_and_package_amount_columns.sql`.
|
||||||
|
|
||||||
|
**Directories:**
|
||||||
|
- Feature/layer folders are lowercase: `frontend/src/context`, `backend/src/services`.
|
||||||
|
- Test directories stay colocated by runtime side (`frontend/src/test`, `backend/src/test`).
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New Feature:**
|
||||||
|
- Primary code:
|
||||||
|
- Frontend UI route/screen: `frontend/src/pages/` (compose from existing `components/`, `hooks/`, `ui/`).
|
||||||
|
- Backend endpoint: `backend/src/routes/` + matching domain logic in `backend/src/services/`.
|
||||||
|
- Persistence additions: `backend/src/db/schema.ts` plus migration updates in `backend/src/db/client.ts` and `backend/drizzle/`.
|
||||||
|
- Tests:
|
||||||
|
- Frontend unit/component: `frontend/src/test/`.
|
||||||
|
- Backend unit/integration: `backend/src/test/`.
|
||||||
|
- E2E flow: `frontend/e2e/`.
|
||||||
|
|
||||||
|
**New Component/Module:**
|
||||||
|
- Implementation:
|
||||||
|
- Shared UI primitive/layout: `frontend/src/ui/`.
|
||||||
|
- Domain-specific UI component: `frontend/src/components/` (or nested feature folder).
|
||||||
|
- Backend reusable domain behavior: `backend/src/services/`.
|
||||||
|
|
||||||
|
**Utilities:**
|
||||||
|
- Shared helpers:
|
||||||
|
- Frontend: `frontend/src/utils/`.
|
||||||
|
- Backend: `backend/src/utils/`.
|
||||||
|
- DB-specific helpers: `backend/src/db/` focused utility modules.
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**frontend/dist, backend/dist:**
|
||||||
|
- Purpose: build output artifacts.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`dist/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**frontend/playwright-report, frontend/test-results, frontend/coverage, backend/coverage:**
|
||||||
|
- Purpose: test artifacts/reports.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**data/:**
|
||||||
|
- Purpose: runtime/local DB, reminder state, scheduler locks.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`data/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**doku/:**
|
||||||
|
- Purpose: local work memory/reporting and internal notes.
|
||||||
|
- Generated: Mixed (manual local notes + artifacts).
|
||||||
|
- Committed: No (`doku/` ignored in `.gitignore`).
|
||||||
|
|
||||||
|
**.planning/codebase/:**
|
||||||
|
- Purpose: generated architecture/stack/convention/concern maps for GSD planning/execution.
|
||||||
|
- Generated: Yes.
|
||||||
|
- Committed: No (`.planning/` ignored by policy in this workspace).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-04-30*
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-30
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- Vitest 4.x for unit/integration tests in both packages:
|
||||||
|
- Frontend config: `frontend/vitest.config.ts`
|
||||||
|
- Backend config: `backend/vitest.config.ts`
|
||||||
|
- Config evidence:
|
||||||
|
- Frontend uses `environment: 'jsdom'` with React setup file `frontend/src/test/setup.ts`.
|
||||||
|
- Backend uses `environment: 'node'` with setup file `backend/src/test/setup.ts`.
|
||||||
|
|
||||||
|
**Assertion Library:**
|
||||||
|
- Vitest `expect`.
|
||||||
|
- Frontend extends DOM assertions via `@testing-library/jest-dom` in `frontend/src/test/setup.ts`.
|
||||||
|
|
||||||
|
**Run Commands:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm test # Watch/unit tests
|
||||||
|
cd frontend && npm run test:run # CI-style frontend run
|
||||||
|
cd frontend && npm run test:coverage # Frontend coverage
|
||||||
|
cd backend && npm test # Watch/unit tests
|
||||||
|
cd backend && npm run test:run # CI-style backend run
|
||||||
|
cd backend && npm run test:coverage # Backend coverage
|
||||||
|
cd frontend && npm run test:e2e # Stable Playwright suite
|
||||||
|
cd frontend && npm run test:e2e:all # Cross-browser Playwright suite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Organization
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Backend unit/integration tests are in `backend/src/test/*.test.ts`.
|
||||||
|
- Frontend unit/component/hook/context tests are in `frontend/src/test/**`.
|
||||||
|
- Browser E2E tests are in `frontend/e2e/*.spec.ts`.
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
- Unit/integration: `*.test.ts` or `*.test.tsx` (for example `backend/src/test/routes-real.test.ts`, `frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||||
|
- E2E: `*.spec.ts` (for example `frontend/e2e/medication-edit.spec.ts`).
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
backend/src/test/
|
||||||
|
setup.ts
|
||||||
|
*.test.ts
|
||||||
|
|
||||||
|
frontend/src/test/
|
||||||
|
setup.ts
|
||||||
|
App.test.tsx
|
||||||
|
components/*.test.tsx
|
||||||
|
context/*.test.tsx
|
||||||
|
hooks/*.test.ts
|
||||||
|
pages/*.test.tsx
|
||||||
|
utils/*.test.ts
|
||||||
|
|
||||||
|
frontend/e2e/
|
||||||
|
auth.setup.ts
|
||||||
|
fixtures/index.ts
|
||||||
|
*.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**Suite Organization:**
|
||||||
|
```typescript
|
||||||
|
describe("Feature Area", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles expected behavior", async () => {
|
||||||
|
// arrange
|
||||||
|
// act
|
||||||
|
// assert
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/components/MobileEditModal.test.tsx`, `backend/src/test/planner.test.ts`.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Setup pattern:
|
||||||
|
- Frontend centralizes browser mocks in `frontend/src/test/setup.ts` (fetch, localStorage, clipboard, history, i18n).
|
||||||
|
- Backend provides reusable app/database factories in `backend/src/test/setup.ts` (`buildTestApp`, `createTestUser`, `createTestMedication`).
|
||||||
|
- Teardown pattern:
|
||||||
|
- `afterAll` closes Fastify app and DB clients (`backend/src/test/planner.test.ts`, `backend/src/test/integration.test.ts`).
|
||||||
|
- Assertion pattern:
|
||||||
|
- Route tests assert both HTTP status and response body (`backend/src/test/routes-real.test.ts`).
|
||||||
|
- UI tests assert presence and behavior via Testing Library role/test-id queries (`frontend/src/test/components/MedicationDialogs.test.tsx`).
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- Vitest mocks (`vi.mock`, `vi.fn`, `vi.hoisted`, `vi.stubGlobal`).
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
```typescript
|
||||||
|
const { testClient, testDb } = vi.hoisted(() => {
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
return { testClient: client, testDb: db };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/integration.test.ts`, `backend/src/test/routes-real.test.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock("../../components/ConfirmModal", () => ({
|
||||||
|
ConfirmModal: ({ onConfirm }) => <button onClick={onConfirm}>confirm</button>,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/components/MedicationDialogs.test.tsx`.
|
||||||
|
|
||||||
|
**What to Mock:**
|
||||||
|
- External side effects and infrastructure boundaries: SMTP/nodemailer, fetch network calls, auth/plugin env modules, browser APIs.
|
||||||
|
- Component dependencies in focused unit tests (replace heavy children with stubs).
|
||||||
|
|
||||||
|
**What NOT to Mock:**
|
||||||
|
- Core business behavior under direct test (route handlers in route tests, hook logic in hook tests, E2E API + UI flow in Playwright).
|
||||||
|
|
||||||
|
## Fixtures and Factories
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
```typescript
|
||||||
|
const userId = await createTestUser(client, { username: "testuser" });
|
||||||
|
const medId = await createTestMedication(client, { userId, name: "Test Medication" });
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/setup.ts`, used by `backend/src/test/medications.test.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const test = base.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
await applyVideoSafetyMode(page);
|
||||||
|
await setupAuthMeMock(page);
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Backend factories/utilities: `backend/src/test/setup.ts`.
|
||||||
|
- Frontend E2E shared fixtures and API helpers: `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Frontend global thresholds in `frontend/vitest.config.ts`: lines/functions/branches/statements = 75.
|
||||||
|
- Backend global thresholds in `backend/vitest.config.ts`: lines 60, functions 65, branches 50, statements 60.
|
||||||
|
|
||||||
|
**View Coverage:**
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run test:coverage
|
||||||
|
cd backend && npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- Component/hook/utils tests in `frontend/src/test/**`.
|
||||||
|
- Utility/service route-unit style tests in `backend/src/test/*.test.ts`.
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- Backend route interaction and multi-route behavior tests in files like:
|
||||||
|
- `backend/src/test/integration.test.ts`
|
||||||
|
- `backend/src/test/routes-real.test.ts`
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
- Playwright used with setup project and browser projects (`frontend/playwright.base.config.ts`).
|
||||||
|
- Auth/session and API seeding helpers in `frontend/e2e/fixtures/index.ts`.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Async Testing:**
|
||||||
|
```typescript
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Pattern evidence: `frontend/src/test/context/AppContext.test.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/routes-real.test.ts`.
|
||||||
|
|
||||||
|
**Error Testing:**
|
||||||
|
```typescript
|
||||||
|
const response = await app.inject({ method: "POST", url: "/planner/send-email", payload: { rows: [] } });
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||||
|
```
|
||||||
|
Pattern evidence: `backend/src/test/planner.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-04-30*
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-639%2F639-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-644%2F644-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-891%2F891-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -203,7 +203,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
|||||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
||||||
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
|
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
|
||||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
|
||||||
|
|
||||||
Recommended values for API docs by environment:
|
Recommended values for API docs by environment:
|
||||||
|
|
||||||
@@ -305,6 +305,8 @@ API reference:
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||||
|
|
||||||
|
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
|
||||||
|
|
||||||
### Push Notifications (Shoutrrr)
|
### Push Notifications (Shoutrrr)
|
||||||
|
|
||||||
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||||
@@ -376,6 +378,14 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
|
|||||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||||
|
|
||||||
|
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
|
||||||
|
|
||||||
|
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
|
||||||
|
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
|
||||||
|
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
|
||||||
|
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
|
||||||
|
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
|
||||||
|
|
||||||
Useful local commands:
|
Useful local commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE `notification_action_groups` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`group_key` text(255) NOT NULL,
|
||||||
|
`sequence_id` text(255) NOT NULL,
|
||||||
|
`dose_ids_json` text NOT NULL,
|
||||||
|
`title` text(255) NOT NULL,
|
||||||
|
`message` text NOT NULL,
|
||||||
|
`language` text(10) DEFAULT 'en' NOT NULL,
|
||||||
|
`scheduled_for` integer,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`resolved_action` text(20),
|
||||||
|
`resolved_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `notification_action_tokens` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`group_id` integer NOT NULL,
|
||||||
|
`token_hash` text(128) NOT NULL,
|
||||||
|
`kind` text(20) NOT NULL,
|
||||||
|
`used_at` integer,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1773348659979,
|
"when": 1773348659979,
|
||||||
"tag": "0013_add_share_medication_overview",
|
"tag": "0013_add_share_medication_overview",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1775849300000,
|
||||||
|
"tag": "0014_add_user_settings_timezone",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+269
-405
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.22.2",
|
"version": "1.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -20,35 +20,35 @@
|
|||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/sensible": "^6.0.4",
|
"@fastify/sensible": "^6.0.4",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.3",
|
||||||
"@fastify/swagger": "^9.7.0",
|
"@fastify/swagger": "^9.7.0",
|
||||||
"@fastify/swagger-ui": "^5.2.5",
|
"@fastify/swagger-ui": "^5.2.6",
|
||||||
"@libsql/client": "^0.17.2",
|
"@libsql/client": "^0.17.3",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"dotenv": "^17.4.1",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fastify": "^5.8.4",
|
"fastify": "^5.8.5",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.3",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.7",
|
||||||
"openid-client": "^6.8.2",
|
"openid-client": "^6.8.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.6.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
@@ -58,6 +59,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
|
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
@@ -74,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -94,6 +97,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_key TEXT NOT NULL UNIQUE,
|
||||||
|
sequence_id TEXT NOT NULL,
|
||||||
|
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
|
||||||
|
dose_ids_json TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
scheduled_for INTEGER,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
resolved_action TEXT,
|
||||||
|
resolved_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -123,6 +151,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
const createIndexMigrations = [
|
const createIndexMigrations = [
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
|
|||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
share_stock_status integer NOT NULL DEFAULT 1,
|
share_stock_status integer NOT NULL DEFAULT 1,
|
||||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -105,10 +105,12 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
|
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
||||||
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
// Current MedAssist versions no longer read or expose this setting in product flows.
|
||||||
|
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
// Whether shared schedule links also embed the medication overview section
|
// Whether shared schedule links also embed the medication overview section
|
||||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||||
// UI timeline visibility preferences
|
// UI timeline visibility preferences
|
||||||
@@ -182,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Groups - Shared action state for reminder notifications
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionGroups = sqliteTable("notification_action_groups", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
groupKey: text("group_key", { length: 255 }).notNull().unique(),
|
||||||
|
sequenceId: text("sequence_id", { length: 255 }).notNull(),
|
||||||
|
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
|
||||||
|
doseIdsJson: text("dose_ids_json").notNull(),
|
||||||
|
title: text("title", { length: 255 }).notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
language: text("language", { length: 10 }).notNull().default("en"),
|
||||||
|
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
resolvedAction: text("resolved_action", { length: 20 }),
|
||||||
|
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Notification Action Tokens - Hashed tokens for public reminder responses
|
||||||
|
// =============================================================================
|
||||||
|
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
groupId: integer("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
|
||||||
|
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
|
||||||
|
kind: text("kind", { length: 20 }).notNull(),
|
||||||
|
usedAt: integer("used_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking - Tracks when doses are marked as taken
|
// Dose Tracking - Tracks when doses are marked as taken
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -193,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+17
-16
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default("3000"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
OPENAPI_DOCS_ENABLED: z
|
OPENAPI_DOCS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
|
|||||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
// Allow new user registrations (auto-enabled if no users exist)
|
// Allow new user registrations (auto-enabled if no users exist)
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
// Disable username/password form login (useful for OIDC-only setups)
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
FORM_LOGIN_ENABLED: z
|
FORM_LOGIN_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "true"),
|
||||||
|
|
||||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
|
|||||||
// Token TTL settings
|
// Token TTL settings
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default("15"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default("7"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -616,7 +616,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
const parsed = updateProfileSchema.safeParse(request.body);
|
const parsed = updateProfileSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ const doseReadResponseSchema = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function getValidationErrorMessage(error: z.ZodError): string {
|
||||||
|
const firstIssue = error.issues[0];
|
||||||
|
if (!firstIssue) {
|
||||||
|
return "Invalid input";
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -301,7 +310,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = markDoseSchema.safeParse(request.body);
|
const parsed = markDoseSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +432,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = dismissDosesSchema.safeParse(request.body);
|
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +599,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
const parsed = shareDoseSchema.safeParse(request.body);
|
const parsed = shareDoseSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: getValidationErrorMessage(parsed.error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.4";
|
const EXPORT_VERSION = "1.5";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -96,7 +96,8 @@ const doseHistorySchema = z.object({
|
|||||||
const refillHistoryExportSchema = z.object({
|
const refillHistoryExportSchema = z.object({
|
||||||
medicationRef: z.string(), // References _exportId
|
medicationRef: z.string(), // References _exportId
|
||||||
packsAdded: z.number().int().min(0).default(0),
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
loosePillsAdded: z.number().int().min(0).default(0),
|
loosePillsAdded: z.number().int().min(0).optional(),
|
||||||
|
quantityAdded: z.number().int().min(0).optional(),
|
||||||
usedPrescription: z.boolean().default(false),
|
usedPrescription: z.boolean().default(false),
|
||||||
refillDate: z.string(), // ISO datetime
|
refillDate: z.string(), // ISO datetime
|
||||||
});
|
});
|
||||||
@@ -108,8 +109,7 @@ const shareLinkSchema = z.object({
|
|||||||
regenerateToken: z.boolean().default(true),
|
regenerateToken: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
const settingsExportSchema = z
|
const settingsSchemaBase = z.object({
|
||||||
.object({
|
|
||||||
// Email notifications
|
// Email notifications
|
||||||
emailEnabled: z.boolean().default(false),
|
emailEnabled: z.boolean().default(false),
|
||||||
notificationEmail: z.string().nullable().optional(),
|
notificationEmail: z.string().nullable().optional(),
|
||||||
@@ -137,8 +137,16 @@ const settingsExportSchema = z
|
|||||||
// UI preferences
|
// UI preferences
|
||||||
language: z.string().default("en"),
|
language: z.string().default("en"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
shareStockStatus: z.boolean().default(true),
|
|
||||||
shareMedicationOverview: z.boolean().default(false),
|
shareMedicationOverview: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportSettingsSchema = settingsSchemaBase.optional();
|
||||||
|
|
||||||
|
const importSettingsSchema = settingsSchemaBase
|
||||||
|
.extend({
|
||||||
|
// Accept the removed field from legacy exports so old backups still import,
|
||||||
|
// but do not map it back into current runtime settings.
|
||||||
|
shareStockStatus: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
@@ -149,7 +157,7 @@ const importDataSchema = z.object({
|
|||||||
medications: z.array(medicationExportSchema).default([]),
|
medications: z.array(medicationExportSchema).default([]),
|
||||||
doseHistory: z.array(doseHistorySchema).default([]),
|
doseHistory: z.array(doseHistorySchema).default([]),
|
||||||
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
refillHistory: z.array(refillHistoryExportSchema).default([]),
|
||||||
settings: settingsExportSchema,
|
settings: importSettingsSchema,
|
||||||
shareLinks: z.array(shareLinkSchema).default([]),
|
shareLinks: z.array(shareLinkSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +218,7 @@ const importBodyOpenApiSchema = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
||||||
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, refillDate: "2026-03-10T12:00:00.000Z" }],
|
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||||
},
|
},
|
||||||
@@ -370,6 +378,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// 1. Load all medications
|
// 1. Load all medications
|
||||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
|
const medicationById = new Map(meds.map((med) => [med.id, med]));
|
||||||
|
|
||||||
// Build medication ID to export ID mapping
|
// Build medication ID to export ID mapping
|
||||||
const medIdToExportId = new Map<number, string>();
|
const medIdToExportId = new Map<number, string>();
|
||||||
@@ -509,7 +518,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
expiryWarningDays: settings.expiryWarningDays,
|
expiryWarningDays: settings.expiryWarningDays,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
stockCalculationMode: settings.stockCalculationMode,
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
shareStockStatus: settings.shareStockStatus,
|
|
||||||
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
shareMedicationOverview: settings.shareMedicationOverview ?? false,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -548,6 +556,13 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
.map((refill) => {
|
.map((refill) => {
|
||||||
const exportId = medIdToExportId.get(refill.medicationId);
|
const exportId = medIdToExportId.get(refill.medicationId);
|
||||||
if (!exportId) return null; // Orphaned refill, skip
|
if (!exportId) return null; // Orphaned refill, skip
|
||||||
|
const medication = medicationById.get(refill.medicationId);
|
||||||
|
const packageType = normalizePackageType(medication?.packageType);
|
||||||
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||||
|
const quantityAdded =
|
||||||
|
packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"
|
||||||
|
? (refill.loosePillsAdded ?? 0)
|
||||||
|
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
|
||||||
|
|
||||||
// Safely convert refillDate to ISO string
|
// Safely convert refillDate to ISO string
|
||||||
let refillDateIso: string;
|
let refillDateIso: string;
|
||||||
@@ -568,6 +583,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
medicationRef: exportId,
|
medicationRef: exportId,
|
||||||
packsAdded: refill.packsAdded ?? 0,
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
||||||
|
quantityAdded,
|
||||||
usedPrescription: refill.usedPrescription ?? false,
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
refillDate: refillDateIso,
|
refillDate: refillDateIso,
|
||||||
};
|
};
|
||||||
@@ -778,6 +794,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// 5. Import settings
|
// 5. Import settings
|
||||||
if (importData.settings) {
|
if (importData.settings) {
|
||||||
|
// Legacy exports may still contain shareStockStatus. The current app no longer
|
||||||
|
// uses that setting, so imports accept it for compatibility and then ignore it.
|
||||||
await db.insert(userSettings).values({
|
await db.insert(userSettings).values({
|
||||||
userId,
|
userId,
|
||||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||||
@@ -802,7 +820,6 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||||
language: importData.settings.language ?? "en",
|
language: importData.settings.language ?? "en",
|
||||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||||
shareStockStatus: importData.settings.shareStockStatus ?? true,
|
|
||||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -830,7 +847,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
medicationId: newMedId,
|
medicationId: newMedId,
|
||||||
userId,
|
userId,
|
||||||
packsAdded: refill.packsAdded ?? 0,
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
loosePillsAdded: refill.loosePillsAdded ?? 0,
|
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||||
usedPrescription: refill.usedPrescription ?? false,
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
refillDate: new Date(refill.refillDate),
|
refillDate: new Date(refill.refillDate),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1203,15 +1203,18 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
|
||||||
const allowsBottleCapacityUpdate = packageType === "bottle";
|
const allowsBottleCapacityUpdate = packageType === "bottle";
|
||||||
if (allowsAmountBaseUpdate) {
|
if (allowsAmountBaseUpdate) {
|
||||||
if (totalPills !== undefined) updateFields.totalPills = totalPills;
|
const normalizedAmountBase = looseTablets ?? totalPills;
|
||||||
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
|
if (normalizedAmountBase !== undefined) {
|
||||||
|
updateFields.totalPills = normalizedAmountBase;
|
||||||
|
updateFields.looseTablets = normalizedAmountBase;
|
||||||
|
}
|
||||||
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
|
||||||
}
|
}
|
||||||
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
|
||||||
updateFields.totalPills = totalPills;
|
updateFields.totalPills = totalPills;
|
||||||
}
|
}
|
||||||
if (packCount !== undefined) updateFields.packCount = packCount;
|
if (packCount !== undefined) updateFields.packCount = packCount;
|
||||||
if (looseTablets !== undefined) {
|
if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
|
||||||
updateFields.looseTablets = looseTablets;
|
updateFields.looseTablets = looseTablets;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1654,7 +1657,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const parsed = dismissUntilSchema.safeParse(req.body);
|
const parsed = dismissUntilSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: parsed.error.errors[0]?.message ?? "Invalid input" });
|
return reply.status(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid input" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications, refillHistory } from "../db/schema.js";
|
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
@@ -18,10 +19,11 @@ const refillSchema = z
|
|||||||
.object({
|
.object({
|
||||||
packsAdded: z.number().int().min(0).default(0),
|
packsAdded: z.number().int().min(0).default(0),
|
||||||
loosePillsAdded: z.number().int().min(0).default(0),
|
loosePillsAdded: z.number().int().min(0).default(0),
|
||||||
|
quantityAdded: z.number().int().min(0).default(0),
|
||||||
usePrescription: z.boolean().default(false),
|
usePrescription: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0, {
|
.refine((data) => data.packsAdded > 0 || data.loosePillsAdded > 0 || data.quantityAdded > 0, {
|
||||||
message: "Must add at least one pack or some loose pills",
|
message: "Must add at least one pack or some quantity",
|
||||||
});
|
});
|
||||||
|
|
||||||
const refillBodyOpenApiSchema = {
|
const refillBodyOpenApiSchema = {
|
||||||
@@ -29,12 +31,14 @@ const refillBodyOpenApiSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer", minimum: 0, default: 0 },
|
packsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
|
loosePillsAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
|
quantityAdded: { type: "integer", minimum: 0, default: 0 },
|
||||||
usePrescription: { type: "boolean", default: false },
|
usePrescription: { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
description: "Provide at least one pack or some loose pills.",
|
description: "Provide at least one pack or some quantity.",
|
||||||
example: {
|
example: {
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 4,
|
loosePillsAdded: 4,
|
||||||
|
quantityAdded: 4,
|
||||||
usePrescription: true,
|
usePrescription: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -49,6 +53,7 @@ const refillResponseSchema = {
|
|||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "number" },
|
||||||
totalPillsAdded: { type: "number" },
|
totalPillsAdded: { type: "number" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -80,6 +85,7 @@ const refillHistoryItemSchema = {
|
|||||||
id: { type: "number" },
|
id: { type: "number" },
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "number" },
|
||||||
totalPillsAdded: { type: "number" },
|
totalPillsAdded: { type: "number" },
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
@@ -136,11 +142,12 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
|
||||||
if (!med) return reply.notFound("Medication not found");
|
if (!med) return reply.notFound("Medication not found");
|
||||||
|
|
||||||
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
|
const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
|
||||||
const packageType = normalizePackageType(med.packageType);
|
const packageType = normalizePackageType(med.packageType);
|
||||||
const isBottle = packageType === "bottle";
|
const isBottle = packageType === "bottle";
|
||||||
const isAmountBased = isAmountBasedPackageType(packageType);
|
const isAmountBased = isAmountBasedPackageType(packageType);
|
||||||
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
const isCountBasedAmountPackage = isAmountBased && !isBottle;
|
||||||
|
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
||||||
|
|
||||||
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
|
||||||
const fallbackAmountPerPackage = Math.max(
|
const fallbackAmountPerPackage = Math.max(
|
||||||
@@ -153,7 +160,9 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
: fallbackAmountPerPackage;
|
: fallbackAmountPerPackage;
|
||||||
|
|
||||||
const requestedPackAdds = Math.max(0, packsAdded);
|
const requestedPackAdds = Math.max(0, packsAdded);
|
||||||
const requestedAmountAdds = Math.max(0, loosePillsAdded);
|
const requestedLooseAdds = Math.max(0, loosePillsAdded);
|
||||||
|
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
|
||||||
|
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
|
||||||
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
|
||||||
|
|
||||||
let effectivePacksAdded = requestedPackAdds;
|
let effectivePacksAdded = requestedPackAdds;
|
||||||
@@ -166,6 +175,9 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
? effectivePacksAdded * amountPerPackage
|
? effectivePacksAdded * amountPerPackage
|
||||||
: requestedAmountAdds;
|
: requestedAmountAdds;
|
||||||
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
|
||||||
|
const totalPillsAdded = isAmountBased
|
||||||
|
? effectiveLoosePillsAdded
|
||||||
|
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
||||||
|
|
||||||
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
|
||||||
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
|
||||||
@@ -183,11 +195,40 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update medication stock
|
const refillBaselineAt = new Date();
|
||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const [settings] = await db
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||||
const previousAmountBase = med.totalPills ?? med.looseTablets;
|
.from(userSettings)
|
||||||
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
|
.where(eq(userSettings.userId, userId));
|
||||||
|
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||||
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
const currentStockAtRefill = computeMedicationCurrentStock({
|
||||||
|
medication: med,
|
||||||
|
doses,
|
||||||
|
stockCalculationMode,
|
||||||
|
nowMs: refillBaselineAt.getTime(),
|
||||||
|
});
|
||||||
|
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
|
||||||
|
|
||||||
|
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
||||||
|
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
||||||
|
let newPackCount = med.packCount + effectivePacksAdded;
|
||||||
|
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
|
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||||
|
let newTotalAmount = med.totalPills ?? med.looseTablets;
|
||||||
|
|
||||||
|
if (isBottle) {
|
||||||
|
newLooseTablets = targetCurrentStock;
|
||||||
|
newStockAdjustment = 0;
|
||||||
|
} else if (isCountBasedAmountPackage) {
|
||||||
|
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
|
||||||
|
newLooseTablets = targetCurrentStock;
|
||||||
|
newTotalAmount = targetCurrentStock;
|
||||||
|
newStockAdjustment = 0;
|
||||||
|
} else {
|
||||||
|
const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
|
||||||
|
newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
|
||||||
|
}
|
||||||
|
|
||||||
let consumedRefills = 0;
|
let consumedRefills = 0;
|
||||||
if (usePrescription) {
|
if (usePrescription) {
|
||||||
@@ -197,10 +238,10 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
|
||||||
: (med.prescriptionRemainingRefills ?? null);
|
: (med.prescriptionRemainingRefills ?? null);
|
||||||
|
|
||||||
const refillBaselineAt = new Date();
|
|
||||||
const updatePayload: {
|
const updatePayload: {
|
||||||
packCount: number;
|
packCount: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
|
stockAdjustment: number;
|
||||||
totalPills?: number;
|
totalPills?: number;
|
||||||
packageAmountValue?: number;
|
packageAmountValue?: number;
|
||||||
prescriptionRemainingRefills: number | null;
|
prescriptionRemainingRefills: number | null;
|
||||||
@@ -209,6 +250,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
} = {
|
} = {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
|
stockAdjustment: newStockAdjustment,
|
||||||
prescriptionRemainingRefills: newRemainingRefills,
|
prescriptionRemainingRefills: newRemainingRefills,
|
||||||
lastStockCorrectionAt: refillBaselineAt,
|
lastStockCorrectionAt: refillBaselineAt,
|
||||||
updatedAt: refillBaselineAt,
|
updatedAt: refillBaselineAt,
|
||||||
@@ -236,31 +278,20 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Calculate pills added for response (packageType-aware)
|
|
||||||
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
|
|
||||||
const totalPillsAdded = isAmountBased
|
|
||||||
? effectiveLoosePillsAdded
|
|
||||||
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
|
|
||||||
let newTotalPills = newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
if (isCountBasedAmountPackage) {
|
|
||||||
newTotalPills = (newTotalAmount ?? 0) + (med.stockAdjustment ?? 0);
|
|
||||||
} else if (isBottle) {
|
|
||||||
newTotalPills = newLooseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
refill: {
|
refill: {
|
||||||
id: refill.id,
|
id: refill.id,
|
||||||
packsAdded: effectivePacksAdded,
|
packsAdded: effectivePacksAdded,
|
||||||
loosePillsAdded: effectiveLoosePillsAdded,
|
loosePillsAdded: effectiveLoosePillsAdded,
|
||||||
|
quantityAdded: totalPillsAdded,
|
||||||
totalPillsAdded,
|
totalPillsAdded,
|
||||||
refillDate: refill.refillDate,
|
refillDate: refill.refillDate,
|
||||||
},
|
},
|
||||||
newStock: {
|
newStock: {
|
||||||
packCount: newPackCount,
|
packCount: newPackCount,
|
||||||
looseTablets: newLooseTablets,
|
looseTablets: newLooseTablets,
|
||||||
totalPills: newTotalPills,
|
totalPills: targetCurrentStock,
|
||||||
},
|
},
|
||||||
prescription: {
|
prescription: {
|
||||||
used: usePrescription,
|
used: usePrescription,
|
||||||
@@ -316,6 +347,7 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
totalPillsAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate,
|
refillDate: r.refillDate,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
|
|
||||||
const reportDataSchema = z.object({
|
const reportDataSchema = z.object({
|
||||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
|
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reportDataBodyOpenApiSchema = {
|
const reportDataBodyOpenApiSchema = {
|
||||||
@@ -26,12 +27,27 @@ const reportDataBodyOpenApiSchema = {
|
|||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: { type: "integer", minimum: 1 },
|
items: { type: "integer", minimum: 1 },
|
||||||
},
|
},
|
||||||
|
takenByFilter: {
|
||||||
|
type: "array",
|
||||||
|
maxItems: 50,
|
||||||
|
items: { type: "string", minLength: 1, maxLength: 100 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
medicationIds: [1, 3, 5],
|
medicationIds: [1, 3, 5],
|
||||||
|
takenByFilter: ["Daniel"],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
||||||
|
if (!takenByFilter) return true;
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 4) return false;
|
||||||
|
const takenBy = parts.at(-1)?.trim();
|
||||||
|
if (!takenBy) return false;
|
||||||
|
return takenByFilter.has(takenBy);
|
||||||
|
}
|
||||||
|
|
||||||
const reportDataResponseSchema = {
|
const reportDataResponseSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
@@ -39,7 +55,7 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
dosesTaken: { type: "integer" },
|
dosesTaken: { type: "integer" },
|
||||||
automaticDosesTaken: { type: "integer" },
|
automaticDosesTaken: { type: "integer" },
|
||||||
dosesDismissed: { type: "integer" },
|
dosesSkipped: { type: "integer" },
|
||||||
firstDoseAt: { type: "string" },
|
firstDoseAt: { type: "string" },
|
||||||
lastDoseAt: { type: "string" },
|
lastDoseAt: { type: "string" },
|
||||||
refills: {
|
refills: {
|
||||||
@@ -49,6 +65,7 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
|
quantityAdded: { type: "integer" },
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -93,10 +110,22 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
const { medicationIds } = parsed.data;
|
const { medicationIds, takenByFilter } = parsed.data;
|
||||||
|
const normalizedTakenByFilter = takenByFilter?.length
|
||||||
|
? new Set(takenByFilter.map((value) => value.trim()))
|
||||||
|
: null;
|
||||||
|
|
||||||
// Verify all medications belong to this user
|
// Verify all medications belong to this user
|
||||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
const userMeds = await db
|
||||||
|
.select({
|
||||||
|
id: medications.id,
|
||||||
|
packageType: medications.packageType,
|
||||||
|
blistersPerPack: medications.blistersPerPack,
|
||||||
|
pillsPerBlister: medications.pillsPerBlister,
|
||||||
|
})
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, userId));
|
||||||
|
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
||||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
for (const id of medicationIds) {
|
for (const id of medicationIds) {
|
||||||
@@ -122,6 +151,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
for (const dose of allDoses) {
|
for (const dose of allDoses) {
|
||||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
|
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({
|
dosesByMed.get(medId)!.push({
|
||||||
takenAt: dose.takenAt,
|
takenAt: dose.takenAt,
|
||||||
@@ -136,10 +166,16 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
automaticDosesTaken: number;
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesSkipped: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
refills: {
|
||||||
|
packsAdded: number;
|
||||||
|
loosePillsAdded: number;
|
||||||
|
quantityAdded: number;
|
||||||
|
usedPrescription: boolean;
|
||||||
|
refillDate: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -147,9 +183,12 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const doses = dosesByMed.get(medId) ?? [];
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
|
const medication = medMap.get(medId);
|
||||||
|
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
||||||
|
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||||
|
|
||||||
// Get refills for this medication scoped to the authenticated user.
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
const refills = await db
|
const refills = await db
|
||||||
@@ -160,12 +199,13 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
result[medId] = {
|
result[medId] = {
|
||||||
dosesTaken: takenDoses.length,
|
dosesTaken: takenDoses.length,
|
||||||
automaticDosesTaken: automaticTakenDoses.length,
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
dosesDismissed: dismissedDoses.length,
|
dosesSkipped: skippedDoses.length,
|
||||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
refills: refills.map((r) => ({
|
refills: refills.map((r) => ({
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
|
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { getSmtpConfig, sendEmailNotification } from "../services/notifications/
|
|||||||
import {
|
import {
|
||||||
classifyTestEmailFailure,
|
classifyTestEmailFailure,
|
||||||
getAllUserSettingsFromDb,
|
getAllUserSettingsFromDb,
|
||||||
|
getAvailableTimezones,
|
||||||
getDefaultSettings,
|
getDefaultSettings,
|
||||||
getNotificationProvider,
|
getNotificationProvider,
|
||||||
loadUserSettingsFromDb,
|
loadUserSettingsFromDb,
|
||||||
|
normalizeSettingsTimezone,
|
||||||
sanitizeNotificationUrl,
|
sanitizeNotificationUrl,
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
validateNotificationHostname,
|
validateNotificationHostname,
|
||||||
@@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js";
|
|||||||
export type { UserSettings } from "../services/settings-service.js";
|
export type { UserSettings } from "../services/settings-service.js";
|
||||||
|
|
||||||
type SettingsBody = {
|
type SettingsBody = {
|
||||||
|
timezone: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string;
|
notificationEmail: string;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
@@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
|
timezone: settings.timezone ?? "",
|
||||||
|
availableTimezones: getAvailableTimezones(),
|
||||||
|
serverTimezone: process.env.TZ || "UTC",
|
||||||
// User notification settings (from DB)
|
// User notification settings (from DB)
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail ?? "",
|
notificationEmail: settings.notificationEmail ?? "",
|
||||||
@@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
||||||
properties: {
|
properties: {
|
||||||
|
timezone: { type: "string" },
|
||||||
emailEnabled: { type: "boolean" },
|
emailEnabled: { type: "boolean" },
|
||||||
notificationEmail: { type: "string" },
|
notificationEmail: { type: "string" },
|
||||||
reminderDaysBefore: { type: "number" },
|
reminderDaysBefore: { type: "number" },
|
||||||
@@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upcomingTodayOnly: false,
|
upcomingTodayOnly: false,
|
||||||
shareScheduleTodayOnly: false,
|
shareScheduleTodayOnly: false,
|
||||||
swapDashboardMainSections: false,
|
swapDashboardMainSections: false,
|
||||||
|
timezone: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
@@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
const settingsData = {
|
const settingsData = {
|
||||||
|
timezone: normalizeSettingsTimezone(body.timezone),
|
||||||
emailEnabled: body.emailEnabled,
|
emailEnabled: body.emailEnabled,
|
||||||
notificationEmail: body.notificationEmail || null,
|
notificationEmail: body.notificationEmail || null,
|
||||||
emailStockReminders: body.emailStockReminders ?? true,
|
emailStockReminders: body.emailStockReminders ?? true,
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
const parsed = createShareSchema.safeParse(request.body);
|
const parsed = createShareSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
error: parsed.error.issues[0]?.message ?? "Invalid input",
|
||||||
code: "VALIDATION_ERROR",
|
code: "VALIDATION_ERROR",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
|
|||||||
const match = doseIdPattern.exec(dose.doseId);
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
if (
|
||||||
|
Number.isNaN(parsedMedicationId) ||
|
||||||
|
Number.isNaN(parsedIntakeIndex) ||
|
||||||
|
Number.isNaN(doseDateOnlyMs) ||
|
||||||
|
parsedMedicationId !== medication.id ||
|
||||||
|
parsedIntakeIndex !== intakeIndex
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
|
|||||||
const match = doseIdPattern.exec(dose.doseId);
|
const match = doseIdPattern.exec(dose.doseId);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
|
|
||||||
|
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
if (
|
||||||
|
Number.isNaN(parsedMedicationId) ||
|
||||||
|
Number.isNaN(parsedIntakeIndex) ||
|
||||||
|
Number.isNaN(doseDateOnlyMs) ||
|
||||||
|
parsedMedicationId !== medication.id ||
|
||||||
|
parsedIntakeIndex !== intakeIndex
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js";
|
|||||||
import {
|
import {
|
||||||
cleanOldIntakeReminders,
|
cleanOldIntakeReminders,
|
||||||
createDefaultIntakeReminderState,
|
createDefaultIntakeReminderState,
|
||||||
getTimezone,
|
getEffectiveTimezone,
|
||||||
getTodaysIntakes,
|
getTodaysIntakes,
|
||||||
getUpcomingIntakes,
|
getUpcomingIntakes,
|
||||||
type IntakeReminderState,
|
type IntakeReminderState,
|
||||||
@@ -83,6 +83,16 @@ function formatIntakeLog(intake: {
|
|||||||
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
|
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
|
||||||
|
const commercialName = med.name?.trim() ?? "";
|
||||||
|
if (commercialName) return commercialName;
|
||||||
|
|
||||||
|
const genericName = med.genericName?.trim() ?? "";
|
||||||
|
if (genericName) return genericName;
|
||||||
|
|
||||||
|
return `Medication #${med.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function autoMarkDueIntakesAsTaken(
|
async function autoMarkDueIntakesAsTaken(
|
||||||
settings: UserSettings & { userId: number },
|
settings: UserSettings & { userId: number },
|
||||||
rows: (typeof medications.$inferSelect)[],
|
rows: (typeof medications.$inferSelect)[],
|
||||||
@@ -137,7 +147,7 @@ async function autoMarkDueIntakesAsTaken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
const medDisplayName = med.name || med.genericName || "";
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
let remainingStock = computeMedicationCurrentStock({
|
let remainingStock = computeMedicationCurrentStock({
|
||||||
medication: med,
|
medication: med,
|
||||||
doses: trackedDoses,
|
doses: trackedDoses,
|
||||||
@@ -425,7 +435,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
|
||||||
|
|
||||||
const locale = getDateLocale(language);
|
const locale = getDateLocale(language);
|
||||||
const tz = getTimezone();
|
const tz = getEffectiveTimezone(settings.timezone ?? null);
|
||||||
|
|
||||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||||
if (autoMarkedCount > 0) {
|
if (autoMarkedCount > 0) {
|
||||||
@@ -488,7 +498,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||||
// Medication-level takenBy (for fallback/display purposes)
|
// Medication-level takenBy (for fallback/display purposes)
|
||||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
const medDisplayName = med.name || med.genericName || "";
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
|
|
||||||
// Process each intake separately to track blisterIndex
|
// Process each intake separately to track blisterIndex
|
||||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
formatInTimezone,
|
formatInTimezone,
|
||||||
getCurrentHourInTimezone,
|
getCurrentHourInTimezone,
|
||||||
getDateOnlyTimestamp,
|
getDateOnlyTimestamp,
|
||||||
|
getEffectiveTimezone,
|
||||||
getMsUntilNextCheck,
|
getMsUntilNextCheck,
|
||||||
getNextScheduledOccurrenceTime,
|
getNextScheduledOccurrenceTime,
|
||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
|
|||||||
expiryDate: string | null;
|
expiryDate: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string {
|
||||||
|
const commercialName = row.name?.trim() ?? "";
|
||||||
|
if (commercialName) return commercialName;
|
||||||
|
|
||||||
|
const genericName = row.genericName?.trim() ?? "";
|
||||||
|
if (genericName) return genericName;
|
||||||
|
|
||||||
|
return `Medication #${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getMedicationsNeedingReminder(
|
async function getMedicationsNeedingReminder(
|
||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
|
|||||||
|
|
||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||||
medsLeft: currentPills,
|
medsLeft: currentPills,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
|||||||
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
||||||
)
|
)
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
name: row.name,
|
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||||
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
||||||
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
||||||
expiryDate: row.prescriptionExpiryDate ?? null,
|
expiryDate: row.prescriptionExpiryDate ?? null,
|
||||||
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = loadReminderState();
|
const state = loadReminderState();
|
||||||
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
|
const userTimezone = getEffectiveTimezone(settings.timezone ?? null);
|
||||||
|
const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone
|
||||||
const userStateKey = `user_${settings.userId}`;
|
const userStateKey = `user_${settings.userId}`;
|
||||||
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
||||||
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js";
|
|||||||
|
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
timezone?: string | null;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string | null;
|
notificationEmail: string | null;
|
||||||
emailStockReminders: boolean;
|
emailStockReminders: boolean;
|
||||||
@@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number {
|
|||||||
|
|
||||||
export function getDefaultSettings() {
|
export function getDefaultSettings() {
|
||||||
return {
|
return {
|
||||||
|
timezone: "",
|
||||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||||
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
|
||||||
@@ -144,6 +146,33 @@ export function getDefaultSettings() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IntlWithSupportedValuesOf = typeof Intl & {
|
||||||
|
supportedValuesOf?: (key: string) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedTimezones: Set<string> | null = null;
|
||||||
|
|
||||||
|
function getTimezoneSet(): Set<string> {
|
||||||
|
if (cachedTimezones) return cachedTimezones;
|
||||||
|
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
|
||||||
|
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
|
||||||
|
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
|
||||||
|
return cachedTimezones;
|
||||||
|
}
|
||||||
|
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
|
||||||
|
return cachedTimezones;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableTimezones(): string[] {
|
||||||
|
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSettingsTimezone(value: string | null | undefined): string {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
if (!trimmed) return "";
|
||||||
|
return getTimezoneSet().has(trimmed) ? trimmed : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function validateNotificationHostname(hostnameRaw: string): string | null {
|
export function validateNotificationHostname(hostnameRaw: string): string | null {
|
||||||
const hostname = hostnameRaw.toLowerCase();
|
const hostname = hostnameRaw.toLowerCase();
|
||||||
|
|
||||||
@@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
|
|||||||
const settings = await getOrCreateUserSettings(userId);
|
const settings = await getOrCreateUserSettings(userId);
|
||||||
return {
|
return {
|
||||||
userId: settings.userId,
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail,
|
notificationEmail: settings.notificationEmail,
|
||||||
emailStockReminders: settings.emailStockReminders,
|
emailStockReminders: settings.emailStockReminders,
|
||||||
@@ -288,6 +318,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
|
|||||||
const allSettings = await db.select().from(userSettings);
|
const allSettings = await db.select().from(userSettings);
|
||||||
return allSettings.map((settings) => ({
|
return allSettings.map((settings) => ({
|
||||||
userId: settings.userId,
|
userId: settings.userId,
|
||||||
|
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||||
emailEnabled: settings.emailEnabled,
|
emailEnabled: settings.emailEnabled,
|
||||||
notificationEmail: settings.notificationEmail,
|
notificationEmail: settings.notificationEmail,
|
||||||
emailStockReminders: settings.emailStockReminders,
|
emailStockReminders: settings.emailStockReminders,
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
@@ -307,10 +308,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.json().error).toBe("Access denied to medication");
|
expect(response.json().error).toBe("Access denied to medication");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should aggregate taken/dismissed doses and refill history", async () => {
|
it("should aggregate taken/skipped doses and refill history", async () => {
|
||||||
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Report Med", ["Daniel"]);
|
||||||
|
|
||||||
// One taken dose and one dismissed dose for the same medication
|
// One taken dose and one skipped dose for the same medication
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
VALUES (?, ?, ?, 0)`,
|
VALUES (?, ?, ?, 0)`,
|
||||||
@@ -337,13 +338,14 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data[medId].dosesTaken).toBe(1);
|
expect(data[medId].dosesTaken).toBe(1);
|
||||||
expect(data[medId].dosesDismissed).toBe(1);
|
expect(data[medId].dosesSkipped).toBe(1);
|
||||||
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
expect(data[medId].firstDoseAt).toBe(new Date(1735344000 * 1000).toISOString());
|
||||||
expect(data[medId].lastDoseAt).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).toHaveLength(1);
|
||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 2,
|
packsAdded: 2,
|
||||||
loosePillsAdded: 5,
|
loosePillsAdded: 5,
|
||||||
|
quantityAdded: 7,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -375,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 0,
|
loosePillsAdded: 0,
|
||||||
|
quantityAdded: 1,
|
||||||
usedPrescription: false,
|
usedPrescription: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2442,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Liquid Stale Client Stock Correction",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 7,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 1050,
|
||||||
|
looseTablets: 1050,
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: 0,
|
||||||
|
packCount: 1,
|
||||||
|
totalPills: 150,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterCorrectionResponse.statusCode).toBe(200);
|
||||||
|
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(correctedMed).toBeTruthy();
|
||||||
|
expect(correctedMed.packCount).toBe(1);
|
||||||
|
expect(correctedMed.totalPills).toBe(150);
|
||||||
|
expect(correctedMed.looseTablets).toBe(150);
|
||||||
|
expect(correctedMed.stockAdjustment).toBe(0);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(150);
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(300);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(300);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0].quantityAdded).toBe(150);
|
||||||
|
|
||||||
|
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(afterRefillResponse.statusCode).toBe(200);
|
||||||
|
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(refilledMed).toBeTruthy();
|
||||||
|
expect(refilledMed.packCount).toBe(2);
|
||||||
|
expect(refilledMed.totalPills).toBe(300);
|
||||||
|
expect(refilledMed.looseTablets).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
it("should persist stockAdjustment in GET /medications", async () => {
|
it("should persist stockAdjustment in GET /medications", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3047,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
}: {
|
||||||
|
medId: number;
|
||||||
|
refillData: {
|
||||||
|
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
|
||||||
|
newStock: { packCount: number; totalPills: number; looseTablets: number };
|
||||||
|
};
|
||||||
|
visibleStockBeforeRefill: number;
|
||||||
|
expectedQuantityAdded: number;
|
||||||
|
expectedPacksAdded: number;
|
||||||
|
expectedAmountPerPackage?: number;
|
||||||
|
}) {
|
||||||
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
|
||||||
|
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
|
||||||
|
|
||||||
|
const historyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/medications/${medId}/refills`,
|
||||||
|
});
|
||||||
|
expect(historyResponse.statusCode).toBe(200);
|
||||||
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
|
packsAdded: expectedPacksAdded,
|
||||||
|
quantityAdded: expectedQuantityAdded,
|
||||||
|
totalPillsAdded: expectedQuantityAdded,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expectedAmountPerPackage) {
|
||||||
|
expect(refillData.newStock.packCount).toBe(
|
||||||
|
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3240,6 +3359,196 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: "bottle",
|
||||||
|
payload: {
|
||||||
|
...bottleMedication,
|
||||||
|
totalPills: 100,
|
||||||
|
looseTablets: 10,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 0,
|
||||||
|
expectedPackCount: 0,
|
||||||
|
expectedLooseTablets: 104,
|
||||||
|
expectedTotalPills: 104,
|
||||||
|
expectedPersistedTotalPills: 100,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blister",
|
||||||
|
payload: {
|
||||||
|
...blisterMedication,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 10,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 0,
|
||||||
|
expectedTotalPills: 14,
|
||||||
|
expectedPersistedTotalPills: null,
|
||||||
|
expectedStockAdjustment: -6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "liquid_container",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 100,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
expectedVisibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 100,
|
||||||
|
expectedResponsePacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 100,
|
||||||
|
expectedPackCount: 2,
|
||||||
|
expectedLooseTablets: 104,
|
||||||
|
expectedTotalPills: 104,
|
||||||
|
expectedPersistedTotalPills: 104,
|
||||||
|
expectedStockAdjustment: 0,
|
||||||
|
},
|
||||||
|
])("should refill from current visible stock after prior consumption for $name", async ({
|
||||||
|
payload,
|
||||||
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
expectedPackCount,
|
||||||
|
expectedLooseTablets,
|
||||||
|
expectedTotalPills,
|
||||||
|
expectedPersistedTotalPills,
|
||||||
|
expectedStockAdjustment,
|
||||||
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
for (let day = 1; day <= 6; day += 1) {
|
||||||
|
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
|
||||||
|
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
||||||
|
VALUES (?, ?, ?, 0)`,
|
||||||
|
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: refillPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded,
|
||||||
|
expectedPacksAdded: expectedResponsePacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(expectedPackCount);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(expectedPackCount);
|
||||||
|
expect(med.looseTablets).toBe(expectedLooseTablets);
|
||||||
|
expect(med.totalPills).toBe(expectedPersistedTotalPills);
|
||||||
|
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refill tube stock from the corrected visible baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...tubeMedication,
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packageAmountUnit: "g",
|
||||||
|
totalPills: 10,
|
||||||
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const correctionResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/medications/${medId}/stock-adjustment`,
|
||||||
|
payload: {
|
||||||
|
stockAdjustment: -6,
|
||||||
|
looseTablets: 10,
|
||||||
|
totalPills: 10,
|
||||||
|
packageAmountValue: 80,
|
||||||
|
packCount: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(correctionResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 4,
|
||||||
|
expectedQuantityAdded: 80,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 80,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(2);
|
||||||
|
expect(refillData.newStock.looseTablets).toBe(84);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(84);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(2);
|
||||||
|
expect(med.looseTablets).toBe(84);
|
||||||
|
expect(med.totalPills).toBe(84);
|
||||||
|
expect(med.stockAdjustment).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3271,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3293,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 180,
|
||||||
|
expectedQuantityAdded: 180,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(180);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(360);
|
expect(refillData.newStock.totalPills).toBe(360);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
@@ -3306,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.looseTablets).toBe(360);
|
expect(med.looseTablets).toBe(360);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
...liquidContainerMedication,
|
||||||
|
packCount: 0,
|
||||||
|
packageAmountValue: 150,
|
||||||
|
totalPills: 300,
|
||||||
|
looseTablets: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const medId = createResponse.json().id;
|
||||||
|
|
||||||
|
const refillResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/medications/${medId}/refill`,
|
||||||
|
payload: { packsAdded: 5, loosePillsAdded: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 300,
|
||||||
|
expectedQuantityAdded: 750,
|
||||||
|
expectedPacksAdded: 5,
|
||||||
|
expectedAmountPerPackage: 150,
|
||||||
|
});
|
||||||
|
expect(refillData.newStock.packCount).toBe(7);
|
||||||
|
expect(refillData.newStock.totalPills).toBe(1050);
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.statusCode).toBe(200);
|
||||||
|
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
||||||
|
expect(med).toBeTruthy();
|
||||||
|
expect(med.packCount).toBe(7);
|
||||||
|
expect(med.totalPills).toBe(1050);
|
||||||
|
expect(med.looseTablets).toBe(1050);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "liquid_container",
|
name: "liquid_container",
|
||||||
@@ -3322,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 180,
|
||||||
expectedPacksAdded: 1,
|
expectedPacksAdded: 1,
|
||||||
expectedLooseAdded: 180,
|
expectedLooseAdded: 180,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 360,
|
expectedTotalPills: 360,
|
||||||
|
expectedAmountPerPackage: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tube",
|
name: "tube",
|
||||||
@@ -3337,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||||
|
expectedVisibleStockBeforeRefill: 80,
|
||||||
expectedPacksAdded: 2,
|
expectedPacksAdded: 2,
|
||||||
expectedLooseAdded: 80,
|
expectedLooseAdded: 80,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 160,
|
expectedTotalPills: 160,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
},
|
},
|
||||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||||
payload,
|
payload,
|
||||||
refillPayload,
|
refillPayload,
|
||||||
|
expectedVisibleStockBeforeRefill,
|
||||||
expectedPacksAdded,
|
expectedPacksAdded,
|
||||||
expectedLooseAdded,
|
expectedLooseAdded,
|
||||||
expectedRemainingRefills,
|
expectedRemainingRefills,
|
||||||
expectedTotalPills,
|
expectedTotalPills,
|
||||||
|
expectedAmountPerPackage,
|
||||||
}) => {
|
}) => {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3366,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||||
|
expectedQuantityAdded: expectedLooseAdded,
|
||||||
|
expectedPacksAdded,
|
||||||
|
expectedAmountPerPackage,
|
||||||
|
});
|
||||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||||
|
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.prescription.used).toBe(true);
|
expect(refillData.prescription.used).toBe(true);
|
||||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||||
@@ -3381,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(historyResponse.json()[0]).toMatchObject({
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
packsAdded: expectedPacksAdded,
|
packsAdded: expectedPacksAdded,
|
||||||
loosePillsAdded: expectedLooseAdded,
|
loosePillsAdded: expectedLooseAdded,
|
||||||
|
quantityAdded: expectedLooseAdded,
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3402,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
expect(refillData.refill.packsAdded).toBe(1);
|
await expectRefillInvariants({
|
||||||
|
medId,
|
||||||
|
refillData,
|
||||||
|
visibleStockBeforeRefill: 80,
|
||||||
|
expectedQuantityAdded: 40,
|
||||||
|
expectedPacksAdded: 1,
|
||||||
|
expectedAmountPerPackage: 40,
|
||||||
|
});
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(40);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(120);
|
expect(refillData.newStock.totalPills).toBe(120);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
|||||||
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("3000")
|
||||||
.default("3000"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
|
PUBLIC_APP_URL: z.string().url().optional(),
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
REFRESH_SECRET: z.string().min(10).optional(),
|
REFRESH_SECRET: z.string().min(10).optional(),
|
||||||
COOKIE_SECRET: z.string().min(10).optional(),
|
COOKIE_SECRET: z.string().min(10).optional(),
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("15")
|
||||||
.default("15"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.default("7")
|
||||||
.default("7"),
|
.transform((v) => parseInt(v, 10)),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("false")
|
||||||
.default("false"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -44,8 +45,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.default("true")
|
||||||
.default("true"),
|
.transform((v) => v === "true"),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||||
});
|
});
|
||||||
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
|
|||||||
expect(result.PORT).toBe(3000);
|
expect(result.PORT).toBe(3000);
|
||||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||||
expect(result.LOG_LEVEL).toBe("info");
|
expect(result.LOG_LEVEL).toBe("info");
|
||||||
|
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
||||||
expect(result.AUTH_ENABLED).toBe(false);
|
expect(result.AUTH_ENABLED).toBe(false);
|
||||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||||
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OIDC URL validation", () => {
|
describe("OIDC URL validation", () => {
|
||||||
|
it("should accept valid PUBLIC_APP_URL", () => {
|
||||||
|
const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" });
|
||||||
|
expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid PUBLIC_APP_URL", () => {
|
||||||
|
expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ describe("Export/Import API", () => {
|
|||||||
expect(data.settings.notificationEmail).toBe("test@example.com");
|
expect(data.settings.notificationEmail).toBe("test@example.com");
|
||||||
expect(data.settings.language).toBe("de");
|
expect(data.settings.language).toBe("de");
|
||||||
expect(data.settings.lowStockDays).toBe(14);
|
expect(data.settings.lowStockDays).toBe(14);
|
||||||
|
expect(data.settings.shareStockStatus).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should exclude sensitive data by default", async () => {
|
it("should exclude sensitive data by default", async () => {
|
||||||
@@ -557,6 +558,45 @@ describe("Export/Import API", () => {
|
|||||||
expect(result.rows[0].loose_tablets).toBe(5);
|
expect(result.rows[0].loose_tablets).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts legacy shareStockStatus in imported settings but does not export or use it", async () => {
|
||||||
|
const importData = {
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
medications: [],
|
||||||
|
doseHistory: [],
|
||||||
|
refillHistory: [],
|
||||||
|
settings: {
|
||||||
|
language: "de",
|
||||||
|
stockCalculationMode: "automatic",
|
||||||
|
shareStockStatus: false,
|
||||||
|
},
|
||||||
|
shareLinks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const importResponse = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
payload: importData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const exportResponse = await ctx.app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportResponse.statusCode).toBe(200);
|
||||||
|
expect(exportResponse.json().settings.shareStockStatus).toBeUndefined();
|
||||||
|
|
||||||
|
const settingsRow = await ctx.client.execute({
|
||||||
|
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(settingsRow.rows[0].share_medication_overview).toBe(0);
|
||||||
|
expect(settingsRow.rows[0].share_stock_status).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("should replace existing data on import", async () => {
|
it("should replace existing data on import", async () => {
|
||||||
// Create existing medication
|
// Create existing medication
|
||||||
await createTestMedication(ctx.client, {
|
await createTestMedication(ctx.client, {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
getThumbFilename,
|
||||||
|
MAX_IMAGE_UPLOAD_BYTES,
|
||||||
|
removeImageFiles,
|
||||||
|
streamToBuffer,
|
||||||
|
writeOptimizedImageSet,
|
||||||
|
} from "../utils/image-upload";
|
||||||
|
|
||||||
|
describe("image-upload utils", () => {
|
||||||
|
const MOCK_TIMESTAMP_MS = 1_700_000_000_000;
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds thumb filename with and without extension", () => {
|
||||||
|
expect(getThumbFilename("avatar.png")).toBe("avatar-thumb.webp");
|
||||||
|
expect(getThumbFilename("avatar")).toBe("avatar-thumb.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes original and thumb files when they exist", () => {
|
||||||
|
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
|
||||||
|
tempDirs.push(imagesDir);
|
||||||
|
|
||||||
|
const imageFilename = "profile.webp";
|
||||||
|
const imagePath = join(imagesDir, imageFilename);
|
||||||
|
const thumbPath = join(imagesDir, getThumbFilename(imageFilename));
|
||||||
|
writeFileSync(imagePath, Buffer.from("image"));
|
||||||
|
writeFileSync(thumbPath, Buffer.from("thumb"));
|
||||||
|
|
||||||
|
removeImageFiles(imagesDir, imageFilename);
|
||||||
|
|
||||||
|
expect(() => readFileSync(imagePath)).toThrow();
|
||||||
|
expect(() => readFileSync(thumbPath)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers stream chunks and rejects payloads above max size", async () => {
|
||||||
|
const stream = Readable.from([Buffer.from("hello"), Buffer.from("world")]);
|
||||||
|
await expect(streamToBuffer(stream)).resolves.toEqual(Buffer.from("helloworld"));
|
||||||
|
|
||||||
|
const oversized = Readable.from([Buffer.alloc(MAX_IMAGE_UPLOAD_BYTES + 1)]);
|
||||||
|
await expect(streamToBuffer(oversized)).rejects.toThrow("IMAGE_TOO_LARGE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes optimized full and thumbnail webp variants", async () => {
|
||||||
|
const imagesDir = mkdtempSync(join(tmpdir(), "medassist-image-upload-"));
|
||||||
|
tempDirs.push(imagesDir);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(MOCK_TIMESTAMP_MS);
|
||||||
|
|
||||||
|
const uploadBuffer = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 64,
|
||||||
|
height: 48,
|
||||||
|
channels: 3,
|
||||||
|
background: { r: 255, g: 0, b: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const result = await writeOptimizedImageSet(imagesDir, "med-42", uploadBuffer, {
|
||||||
|
maxEdgePx: 32,
|
||||||
|
thumbSizePx: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.filename).toBe("med-42-1700000000000.webp");
|
||||||
|
expect(result.thumbFilename).toBe("med-42-1700000000000-thumb.webp");
|
||||||
|
|
||||||
|
const optimizedMeta = await sharp(join(imagesDir, result.filename)).metadata();
|
||||||
|
const thumbMeta = await sharp(join(imagesDir, result.thumbFilename)).metadata();
|
||||||
|
expect(optimizedMeta.format).toBe("webp");
|
||||||
|
expect(thumbMeta.format).toBe("webp");
|
||||||
|
expect(Math.max(optimizedMeta.width ?? 0, optimizedMeta.height ?? 0)).toBeLessThanOrEqual(32);
|
||||||
|
expect(thumbMeta.width).toBe(16);
|
||||||
|
expect(thumbMeta.height).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
|
|||||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id integer NOT NULL UNIQUE,
|
user_id integer NOT NULL UNIQUE,
|
||||||
|
timezone text NOT NULL DEFAULT '',
|
||||||
email_enabled integer NOT NULL DEFAULT 0,
|
email_enabled integer NOT NULL DEFAULT 0,
|
||||||
notification_email text,
|
notification_email text,
|
||||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
|||||||
OIDC_ENABLED: false,
|
OIDC_ENABLED: false,
|
||||||
OIDC_PROVIDER_NAME: "SSO",
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
CORS_ORIGINS: "https://app.example.com",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
testClient: client,
|
testClient: client,
|
||||||
@@ -351,7 +353,7 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
it("POST /settings/test-shoutrrr returns 200 for a valid ntfy target", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-test-message-id" }) });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -361,6 +363,44 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
expect(response.json()).toEqual({ success: true, message: "Test notification sent successfully" });
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
|
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["X-Sequence-ID"]).toEqual(expect.stringMatching(/^medassist-/));
|
||||||
|
expect(JSON.parse(headers.Actions ?? "[]")).toEqual([
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Take",
|
||||||
|
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||||
|
method: "POST",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "http",
|
||||||
|
label: "Skip",
|
||||||
|
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||||
|
method: "POST",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
label: "View",
|
||||||
|
url: "https://app.example.com/dashboard",
|
||||||
|
clear: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
|
||||||
|
expect(Number(groups.rows[0].count)).toBe(1);
|
||||||
|
|
||||||
|
const storedGroup = await testClient.execute(
|
||||||
|
"SELECT ntfy_original_message_id FROM notification_action_groups LIMIT 1"
|
||||||
|
);
|
||||||
|
expect(storedGroup.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-test-message-id" })]);
|
||||||
|
|
||||||
|
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
|
||||||
|
expect(Number(tokens.rows[0].count)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
it("sendShoutrrrNotification blocks localhost/private targets", async () => {
|
||||||
@@ -370,11 +410,12 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
it("sendShoutrrrNotification handles ntfy auth and safe URL reconstruction", async () => {
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: "ntfy-message-id" }) });
|
||||||
|
|
||||||
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
const result = await sendShoutrrrNotification("ntfy://user:pass@ntfy.sh/mytopic", "Title ä", "Message");
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.providerMessageId).toBe("ntfy-message-id");
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
"https://ntfy.sh/mytopic",
|
"https://ntfy.sh/mytopic",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -589,8 +630,35 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const body = response.json();
|
const body = response.json();
|
||||||
expect(body[medId].dosesTaken).toBe(1);
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
expect(body[medId].dosesDismissed).toBe(1);
|
expect(body[medId].dosesSkipped).toBe(1);
|
||||||
expect(body[medId].refills).toHaveLength(1);
|
expect(body[medId].refills).toHaveLength(1);
|
||||||
|
expect(body[medId].refills[0].quantityAdded).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => {
|
||||||
|
const medId = await seedMedication("Report Filter Med");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000000000-Alice`, 1700000000, 0],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [1, `${medId}-0-1700001200000-Bob`, 1700001200, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId], takenByFilter: ["Alice"] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body[medId].dosesTaken).toBe(1);
|
||||||
|
expect(body[medId].dosesSkipped).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||||
@@ -621,7 +689,9 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(body.medications).toHaveLength(1);
|
expect(body.medications).toHaveLength(1);
|
||||||
expect(body.doseHistory).toHaveLength(1);
|
expect(body.doseHistory).toHaveLength(1);
|
||||||
expect(body.refillHistory).toHaveLength(1);
|
expect(body.refillHistory).toHaveLength(1);
|
||||||
|
expect(body.refillHistory[0].quantityAdded).toBe(23);
|
||||||
expect(body.settings.language).toBe("de");
|
expect(body.settings.language).toBe("de");
|
||||||
|
expect(body.settings.shareStockStatus).toBeUndefined();
|
||||||
expect(body.shareLinks).toHaveLength(1);
|
expect(body.shareLinks).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -672,7 +742,15 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
doseHistory: [],
|
doseHistory: [],
|
||||||
refillHistory: [],
|
refillHistory: [
|
||||||
|
{
|
||||||
|
medicationRef: "med-1",
|
||||||
|
packsAdded: 0,
|
||||||
|
quantityAdded: 4,
|
||||||
|
usedPrescription: false,
|
||||||
|
refillDate: "2026-01-02T08:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
settings: {
|
settings: {
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
notificationEmail: null,
|
notificationEmail: null,
|
||||||
@@ -708,10 +786,24 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
expect(valid.statusCode).toBe(200);
|
expect(valid.statusCode).toBe(200);
|
||||||
expect(valid.json().imported.medications).toBe(1);
|
expect(valid.json().imported.medications).toBe(1);
|
||||||
|
expect(valid.json().imported.refillHistory).toBe(1);
|
||||||
|
|
||||||
const rows = await testClient.execute({
|
const rows = await testClient.execute({
|
||||||
sql: "SELECT name FROM medications WHERE user_id = 1",
|
sql: "SELECT name FROM medications WHERE user_id = 1",
|
||||||
});
|
});
|
||||||
expect(rows.rows[0].name).toBe("Imported Med");
|
expect(rows.rows[0].name).toBe("Imported Med");
|
||||||
|
|
||||||
|
const refillRows = await testClient.execute({
|
||||||
|
sql: "SELECT packs_added, loose_pills_added FROM refill_history WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(refillRows.rows).toHaveLength(1);
|
||||||
|
expect(refillRows.rows[0].packs_added).toBe(0);
|
||||||
|
expect(refillRows.rows[0].loose_pills_added).toBe(4);
|
||||||
|
|
||||||
|
const importedSettings = await testClient.execute({
|
||||||
|
sql: "SELECT share_medication_overview, share_stock_status FROM user_settings WHERE user_id = 1",
|
||||||
|
});
|
||||||
|
expect(importedSettings.rows[0].share_medication_overview).toBe(0);
|
||||||
|
expect(importedSettings.rows[0].share_stock_status).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
|
|||||||
|
|
||||||
async function createMedication(options: {
|
async function createMedication(options: {
|
||||||
name: string;
|
name: string;
|
||||||
|
genericName?: string | null;
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
pillsPerBlister?: number;
|
pillsPerBlister?: number;
|
||||||
@@ -80,6 +81,7 @@ async function createMedication(options: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
|
genericName = null,
|
||||||
packCount = 1,
|
packCount = 1,
|
||||||
blistersPerPack = 1,
|
blistersPerPack = 1,
|
||||||
pillsPerBlister = 10,
|
pillsPerBlister = 10,
|
||||||
@@ -106,16 +108,17 @@ async function createMedication(options: {
|
|||||||
|
|
||||||
const result = await testClient.execute({
|
const result = await testClient.execute({
|
||||||
sql: `INSERT INTO medications (
|
sql: `INSERT INTO medications (
|
||||||
user_id, name, taken_by_json, package_type,
|
user_id, name, generic_name, taken_by_json, package_type,
|
||||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
stock_adjustment, last_stock_correction_at,
|
stock_adjustment, last_stock_correction_at,
|
||||||
usage_json, every_json, start_json, intakes_json,
|
usage_json, every_json, start_json, intakes_json,
|
||||||
is_obsolete, intake_reminders_enabled
|
is_obsolete, intake_reminders_enabled
|
||||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
args: [
|
args: [
|
||||||
1,
|
1,
|
||||||
name,
|
name,
|
||||||
|
genericName,
|
||||||
JSON.stringify(takenBy),
|
JSON.stringify(takenBy),
|
||||||
packCount,
|
packCount,
|
||||||
blistersPerPack,
|
blistersPerPack,
|
||||||
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
|||||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
await createMedication({
|
||||||
|
name: "",
|
||||||
|
genericName: "Acetylsalicylic acid",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getLiquidReminderThresholds", () => {
|
describe("getLiquidReminderThresholds", () => {
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
|
|||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalDateOrdinal(date: Date): number {
|
||||||
|
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocalCalendarDays(date: Date, days: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setDate(next.getDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDateOnlyTimestamp(date: Date): number {
|
export function getDateOnlyTimestamp(date: Date): number {
|
||||||
return toDateOnly(date).getTime();
|
return toDateOnly(date).getTime();
|
||||||
}
|
}
|
||||||
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
|
|||||||
|
|
||||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
if (startTime >= lowerBound) {
|
if (startTime >= lowerBound) {
|
||||||
return startTime;
|
return startTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
const lowerBoundDate = new Date(lowerBound);
|
||||||
return startTime + intervals * period;
|
const startOrdinal = getLocalDateOrdinal(startDate);
|
||||||
|
const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
|
||||||
|
const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
|
||||||
|
const wholeIntervals = Math.floor(daysBetween / intervalDays);
|
||||||
|
|
||||||
|
let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
|
||||||
|
while (candidate.getTime() < lowerBound) {
|
||||||
|
candidate = addLocalCalendarDays(candidate, intervalDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateStart = Math.max(lowerBound, startTime);
|
const candidateStart = Math.max(lowerBound, startTime);
|
||||||
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schedule.scheduleMode !== "weekdays") {
|
if (schedule.scheduleMode !== "weekdays") {
|
||||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
const intervalDays = Math.max(1, schedule.every);
|
||||||
let occurrenceMs = startTime;
|
let occurrence = new Date(startDate);
|
||||||
if (occurrenceMs < rangeStartMs) {
|
if (occurrence.getTime() < rangeStartMs) {
|
||||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
const rangeStartDate = new Date(rangeStartMs);
|
||||||
occurrenceMs += intervals * period;
|
const startOrdinal = getLocalDateOrdinal(startDate);
|
||||||
|
const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
|
||||||
|
const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
|
||||||
|
const wholeIntervals = Math.floor(daysBetween / intervalDays);
|
||||||
|
occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
|
||||||
|
|
||||||
|
while (occurrence.getTime() < rangeStartMs) {
|
||||||
|
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
|
for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
|
||||||
if (occurrenceMs >= rangeStartMs) {
|
if (occurrenceMs >= rangeStartMs) {
|
||||||
callback(occurrenceMs);
|
callback(occurrenceMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||||
|
occurrenceMs = occurrence.getTime();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,6 +379,23 @@ export function getTimezone(): string {
|
|||||||
return process.env.TZ || "UTC";
|
return process.env.TZ || "UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidTimezone(value: string): boolean {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveTimezone(override?: string | null): string {
|
||||||
|
const normalized = override?.trim() ?? "";
|
||||||
|
if (normalized && isValidTimezone(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
/** Format a date in the configured timezone */
|
/** Format a date in the configured timezone */
|
||||||
export function formatInTimezone(date: Date, tz?: string): string {
|
export function formatInTimezone(date: Date, tz?: string): string {
|
||||||
return date.toLocaleString("de-DE", {
|
return date.toLocaleString("de-DE", {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Scope and behavior:
|
|||||||
|
|
||||||
- These values are applied only when a user's settings are created for the first time.
|
- These values are applied only when a user's settings are created for the first time.
|
||||||
- After that, values stored in the database are used and take precedence.
|
- After that, values stored in the database are used and take precedence.
|
||||||
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
|
||||||
|
|
||||||
## Email Defaults
|
## Email Defaults
|
||||||
|
|
||||||
@@ -47,6 +46,6 @@ Scope and behavior:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
||||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||||
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
|
|||||||
Generated
+298
-284
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.2",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -27,30 +27,30 @@
|
|||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.8",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.5",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.6",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.5",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ function AppContent() {
|
|||||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/medications" element={<MedicationsPage />} />
|
<Route path="/medications" element={<MedicationsPage />} />
|
||||||
|
|||||||
@@ -1105,10 +1105,7 @@ export function MedDetailModal({
|
|||||||
</span>
|
</span>
|
||||||
<span className="refill-amount">
|
<span className="refill-amount">
|
||||||
{(() => {
|
{(() => {
|
||||||
const total = isAmountBasedPackageType(selectedMed.packageType)
|
const total = entry.quantityAdded;
|
||||||
? entry.loosePillsAdded
|
|
||||||
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
|
|
||||||
entry.loosePillsAdded;
|
|
||||||
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
|
||||||
})()}
|
})()}
|
||||||
{entry.usedPrescription && (
|
{entry.usedPrescription && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Medication } from "../types";
|
|||||||
import {
|
import {
|
||||||
getMedDisplayName,
|
getMedDisplayName,
|
||||||
getMedTotal,
|
getMedTotal,
|
||||||
|
getStockDisplayCapacity,
|
||||||
isAmountBasedPackageType,
|
isAmountBasedPackageType,
|
||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
@@ -27,10 +28,16 @@ type ReportData = Record<
|
|||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
automaticDosesTaken: number;
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesSkipped: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
refills: {
|
||||||
|
packsAdded: number;
|
||||||
|
loosePillsAdded?: number;
|
||||||
|
quantityAdded: number;
|
||||||
|
usedPrescription: boolean;
|
||||||
|
refillDate: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -121,7 +128,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
const res = await fetch("/api/medications/report-data", {
|
const res = await fetch("/api/medications/report-data", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ medicationIds: Array.from(selectedIds) }),
|
body: JSON.stringify({
|
||||||
|
medicationIds: Array.from(selectedIds),
|
||||||
|
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
|
||||||
|
}),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to fetch report data");
|
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||||
@@ -374,7 +384,7 @@ function generateTextReport(
|
|||||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||||
} else {
|
} else {
|
||||||
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med))));
|
||||||
}
|
}
|
||||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
@@ -415,12 +425,12 @@ function generateTextReport(
|
|||||||
const data = reportData[med.id];
|
const data = reportData[med.id];
|
||||||
if (data) {
|
if (data) {
|
||||||
lines.push(h3(t("report.docIntakeHistory")));
|
lines.push(h3(t("report.docIntakeHistory")));
|
||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
|
||||||
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
||||||
if (data.automaticDosesTaken > 0) {
|
if (data.automaticDosesTaken > 0) {
|
||||||
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
||||||
}
|
}
|
||||||
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
if (data.dosesSkipped > 0) lines.push(item(t("report.docDosesSkipped"), String(data.dosesSkipped)));
|
||||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
|
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
|
||||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
|
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(data.lastDoseAt)));
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +442,7 @@ function generateTextReport(
|
|||||||
if (data.refills.length > 0) {
|
if (data.refills.length > 0) {
|
||||||
lines.push(h3(t("report.docRefillHistory")));
|
lines.push(h3(t("report.docRefillHistory")));
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||||
}
|
}
|
||||||
@@ -572,7 +582,7 @@ function buildPrintHtml(
|
|||||||
if (med.looseTablets > 0)
|
if (med.looseTablets > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
|
||||||
}
|
}
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||||
@@ -616,14 +626,14 @@ function buildPrintHtml(
|
|||||||
// Intake history
|
// Intake history
|
||||||
if (data) {
|
if (data) {
|
||||||
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
|
||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
|
||||||
s += `<table><tbody>`;
|
s += `<table><tbody>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||||
if (data.automaticDosesTaken > 0) {
|
if (data.automaticDosesTaken > 0) {
|
||||||
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||||
}
|
}
|
||||||
if (data.dosesDismissed > 0)
|
if (data.dosesSkipped > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
|
||||||
if (data.firstDoseAt)
|
if (data.firstDoseAt)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
|
||||||
if (data.lastDoseAt)
|
if (data.lastDoseAt)
|
||||||
@@ -638,7 +648,7 @@ function buildPrintHtml(
|
|||||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||||
s += `<ul>`;
|
s += `<ul>`;
|
||||||
for (const r of data.refills) {
|
for (const r of data.refills) {
|
||||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||||
s += `<li>${entry}</li>`;
|
s += `<li>${entry}</li>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function SharedSchedule() {
|
|||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
|
const mutationInFlightRef = useRef(0);
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
@@ -183,15 +184,23 @@ export function SharedSchedule() {
|
|||||||
// Separates taken and dismissed doses (like main app's useDoses hook)
|
// Separates taken and dismissed doses (like main app's useDoses hook)
|
||||||
const loadTakenDoses = useCallback(async () => {
|
const loadTakenDoses = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
if (mutationInFlightRef.current > 0) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/share/${token}/doses`);
|
const res = await fetch(`/api/share/${token}/doses`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
if (mutationInFlightRef.current > 0) return;
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const automatic = new Set<string>();
|
const automatic = new Set<string>();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean; takenSource?: string }>) {
|
for (const d of data.doses as Array<{
|
||||||
if (d.dismissed) {
|
doseId: string;
|
||||||
|
dismissed?: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
takenSource?: string;
|
||||||
|
}>) {
|
||||||
|
if (d.skipped === true || d.dismissed === true) {
|
||||||
dismissed.add(d.doseId);
|
dismissed.add(d.doseId);
|
||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
@@ -203,15 +212,9 @@ export function SharedSchedule() {
|
|||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setAutomaticTakenDoses(automatic);
|
setAutomaticTakenDoses(automatic);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
} else {
|
|
||||||
setTakenDoses(new Set());
|
|
||||||
setAutomaticTakenDoses(new Set());
|
|
||||||
setDismissedDoses(new Set());
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setTakenDoses(new Set());
|
// Keep the current optimistic/shared state on transient read errors.
|
||||||
setAutomaticTakenDoses(new Set());
|
|
||||||
setDismissedDoses(new Set());
|
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@@ -232,12 +235,22 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markDoseTaken(doseId: string) {
|
async function markDoseTaken(doseId: string) {
|
||||||
|
const wasTaken = takenDoses.has(doseId);
|
||||||
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
|
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
|
mutationInFlightRef.current++;
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(doseId);
|
next.add(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
setAutomaticTakenDoses((prev) => {
|
setAutomaticTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
@@ -266,16 +279,104 @@ export function SharedSchedule() {
|
|||||||
// Revert on error
|
// Revert on error
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
if (wasTaken) {
|
||||||
|
next.add(doseId);
|
||||||
|
} else {
|
||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasSkipped) {
|
||||||
|
next.add(doseId);
|
||||||
|
} else {
|
||||||
|
next.delete(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAutomaticTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasAutomatic) {
|
||||||
|
next.add(doseId);
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
|
loadTakenDoses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markDoseSkipped(doseId: string) {
|
||||||
|
if (takenDoses.has(doseId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasTaken = takenDoses.has(doseId);
|
||||||
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
|
|
||||||
|
mutationInFlightRef.current++;
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAutomaticTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/share/${token}/doses/skip`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ doseId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to mark shared dose as skipped");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasSkipped) {
|
||||||
|
next.add(doseId);
|
||||||
|
} else {
|
||||||
|
next.delete(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasTaken) {
|
||||||
|
next.add(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAutomaticTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasAutomatic) {
|
||||||
|
next.add(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
loadTakenDoses();
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function undoDoseTaken(doseId: string) {
|
async function undoDoseTaken(doseId: string) {
|
||||||
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
|
mutationInFlightRef.current++;
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
@@ -299,9 +400,100 @@ export function SharedSchedule() {
|
|||||||
next.add(doseId);
|
next.add(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setAutomaticTakenDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasAutomatic) {
|
||||||
|
next.add(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function undoDoseSkipped(doseId: string) {
|
||||||
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
|
|
||||||
|
mutationInFlightRef.current++;
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/share/${token}/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (wasSkipped) {
|
||||||
|
next.add(doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
|
loadTakenDoses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderDoseActionButtons = (options: {
|
||||||
|
doseId: string;
|
||||||
|
isTaken: boolean;
|
||||||
|
isSkipped: boolean;
|
||||||
|
isAutomaticallyTaken: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
}) => {
|
||||||
|
const takeButton = options.isTaken ? (
|
||||||
|
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||||
|
{options.isAutomaticallyTaken && (
|
||||||
|
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||||
|
onClick={() => markDoseTaken(options.doseId)}
|
||||||
|
disabled={options.isEmpty}
|
||||||
|
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||||
|
>
|
||||||
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const skipButton = options.isSkipped ? (
|
||||||
|
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||||
|
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="dose-btn skip"
|
||||||
|
onClick={() => markDoseSkipped(options.doseId)}
|
||||||
|
title={t("dose.markAsSkipped")}
|
||||||
|
disabled={options.isTaken}
|
||||||
|
>
|
||||||
|
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{takeButton}
|
||||||
|
{skipButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
|
const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -934,6 +1126,7 @@ export function SharedSchedule() {
|
|||||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||||
|
const isSkipped = dismissedDoses.has(dose.id);
|
||||||
const doseClasses = ["dose-item", "past"];
|
const doseClasses = ["dose-item", "past"];
|
||||||
if (isTaken) doseClasses.push("all-taken");
|
if (isTaken) doseClasses.push("all-taken");
|
||||||
if (isEmpty) doseClasses.push("med-empty");
|
if (isEmpty) doseClasses.push("med-empty");
|
||||||
@@ -948,37 +1141,17 @@ export function SharedSchedule() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||||
|
>
|
||||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId: dose.id,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(dose.id)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
|
||||||
onClick={() => markDoseTaken(dose.id)}
|
|
||||||
disabled={isEmpty}
|
|
||||||
title={
|
|
||||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1149,7 +1322,8 @@ export function SharedSchedule() {
|
|||||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
const isSkipped = dismissedDoses.has(dose.id);
|
||||||
|
const isOverdue = dose.when < Date.now() && !isTaken && !isSkipped && !isEmpty;
|
||||||
const doseClasses = ["dose-item"];
|
const doseClasses = ["dose-item"];
|
||||||
if (isOverdue) doseClasses.push("overdue");
|
if (isOverdue) doseClasses.push("overdue");
|
||||||
if (isTaken) doseClasses.push("all-taken");
|
if (isTaken) doseClasses.push("all-taken");
|
||||||
@@ -1166,38 +1340,16 @@ export function SharedSchedule() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div
|
<div
|
||||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||||
>
|
>
|
||||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId: dose.id,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(dose.id)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
|
||||||
onClick={() => markDoseTaken(dose.id)}
|
|
||||||
title={
|
|
||||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
|
||||||
}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1351,6 +1503,7 @@ export function SharedSchedule() {
|
|||||||
const isTaken = isDoseTakenForDisplay(dose.id);
|
const isTaken = isDoseTakenForDisplay(dose.id);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(dose.id) && dose.when <= Date.now();
|
||||||
|
const isSkipped = dismissedDoses.has(dose.id);
|
||||||
const doseClasses = ["dose-item", "future"];
|
const doseClasses = ["dose-item", "future"];
|
||||||
if (isTaken) doseClasses.push("all-taken");
|
if (isTaken) doseClasses.push("all-taken");
|
||||||
if (isEmpty) doseClasses.push("med-empty");
|
if (isEmpty) doseClasses.push("med-empty");
|
||||||
@@ -1365,37 +1518,17 @@ export function SharedSchedule() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
className={`dose-person ${isTaken ? "taken" : ""} ${isSkipped ? "skipped" : ""}`}
|
||||||
|
>
|
||||||
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId: dose.id,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(dose.id)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty: true,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
↩
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
|
||||||
onClick={() => markDoseTaken(dose.id)}
|
|
||||||
title={
|
|
||||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
|
||||||
}
|
|
||||||
disabled={true}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export function MedicationListSection({
|
|||||||
const renderImageAvatar = (med: Medication) => (
|
const renderImageAvatar = (med: Medication) => (
|
||||||
<span
|
<span
|
||||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||||
onClick={() => med.imageUrl && onImagePreview(med)}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
|
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
|
||||||
onImagePreview(med);
|
onImagePreview(med);
|
||||||
@@ -146,8 +145,7 @@ export function MedicationListSection({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{t("medications.details.totalCapacity")}:{" "}
|
{t("medications.details.totalCapacity")}: <strong>{stockDisplayCapacity}</strong>
|
||||||
<strong>{med.totalPills ?? med.looseTablets}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,7 +121,12 @@ export function useRefill(): UseRefillReturn {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ packsAdded: refillPacks, loosePillsAdded: refillLoose, usePrescription }),
|
body: JSON.stringify({
|
||||||
|
packsAdded: refillPacks,
|
||||||
|
loosePillsAdded: refillLoose,
|
||||||
|
quantityAdded: refillLoose,
|
||||||
|
usePrescription,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -267,6 +272,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
|
// Keep packageAmountValue (ml per bottle) and update capacity base by bottle count.
|
||||||
patchBody.packCount = correctedLiquidBottleCount;
|
patchBody.packCount = correctedLiquidBottleCount;
|
||||||
patchBody.totalPills = liquidStructuralMax;
|
patchBody.totalPills = liquidStructuralMax;
|
||||||
|
patchBody.looseTablets = liquidStructuralMax;
|
||||||
} else if (!isAmountPackage) {
|
} else if (!isAmountPackage) {
|
||||||
patchBody.looseTablets = finalLoosePills;
|
patchBody.looseTablets = finalLoosePills;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
|
timezone: string;
|
||||||
|
availableTimezones: string[];
|
||||||
|
serverTimezone: string;
|
||||||
emailEnabled: boolean;
|
emailEnabled: boolean;
|
||||||
notificationEmail: string;
|
notificationEmail: string;
|
||||||
reminderDaysBefore: number;
|
reminderDaysBefore: number;
|
||||||
@@ -58,6 +61,9 @@ export interface Settings {
|
|||||||
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
|
timezone: "",
|
||||||
|
availableTimezones: [],
|
||||||
|
serverTimezone: "UTC",
|
||||||
emailEnabled: false,
|
emailEnabled: false,
|
||||||
notificationEmail: "",
|
notificationEmail: "",
|
||||||
reminderDaysBefore: 7,
|
reminderDaysBefore: 7,
|
||||||
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
|
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
timezone: settingsToSave.timezone,
|
||||||
emailEnabled: effectiveEmailEnabled,
|
emailEnabled: effectiveEmailEnabled,
|
||||||
notificationEmail: settingsToSave.notificationEmail,
|
notificationEmail: settingsToSave.notificationEmail,
|
||||||
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
||||||
|
|||||||
@@ -389,6 +389,14 @@
|
|||||||
"title": "Sprache",
|
"title": "Sprache",
|
||||||
"select": "Sprache auswählen"
|
"select": "Sprache auswählen"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"select": "Zeitzone",
|
||||||
|
"hint": "IANA-Zeitzone wählen. Wenn gesetzt, überschreibt sie die Server-TZ für deine Reminder-Zeitpunkte.",
|
||||||
|
"useServerDefault": "Server-Standard nutzen",
|
||||||
|
"currentServerTz": "Server-Standardzeitzone: {{timezone}}",
|
||||||
|
"saving": "Zeitzone wird gespeichert...",
|
||||||
|
"saved": "Zeitzone gespeichert"
|
||||||
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "API-Zugriff",
|
"title": "API-Zugriff",
|
||||||
"generateTitle": "API-Key erzeugen",
|
"generateTitle": "API-Key erzeugen",
|
||||||
|
|||||||
@@ -389,6 +389,14 @@
|
|||||||
"title": "Language",
|
"title": "Language",
|
||||||
"select": "Select language"
|
"select": "Select language"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"select": "Timezone",
|
||||||
|
"hint": "Select an IANA timezone. When set, this overrides server TZ for your reminder timing.",
|
||||||
|
"useServerDefault": "Use server default",
|
||||||
|
"currentServerTz": "Server default timezone: {{timezone}}",
|
||||||
|
"saving": "Saving timezone...",
|
||||||
|
"saved": "Timezone saved"
|
||||||
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"title": "API Access",
|
"title": "API Access",
|
||||||
"generateTitle": "Generate API key",
|
"generateTitle": "Generate API key",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
|
||||||
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
||||||
@@ -28,9 +29,54 @@ import {
|
|||||||
userStorageKey,
|
userStorageKey,
|
||||||
} from "./dashboard-helpers";
|
} from "./dashboard-helpers";
|
||||||
|
|
||||||
|
function getRouteDateKey(value: Date): string {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMedicationIdFromNotificationDoseId(doseId: string | null): string | null {
|
||||||
|
if (!doseId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawMedicationId] = doseId.split("-");
|
||||||
|
return rawMedicationId?.trim() ? rawMedicationId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFocusTargetElement(doseId: string | null, medId: string | null): HTMLElement | null {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doseId) {
|
||||||
|
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-dose-id]"));
|
||||||
|
const doseElement = elements.find((element) => element.dataset.doseId === doseId);
|
||||||
|
if (doseElement) {
|
||||||
|
return doseElement.closest<HTMLElement>("[data-med-id]") ?? doseElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (medId) {
|
||||||
|
const elements = Array.from(document.querySelectorAll<HTMLElement>("[data-med-id]"));
|
||||||
|
return elements.find((element) => element.dataset.medId === medId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDosePeople(takenBy: unknown): Array<string | null> {
|
||||||
|
const takenByArray = Array.isArray(takenBy) ? takenBy : [];
|
||||||
|
return takenByArray.length > 0 ? takenByArray : [null];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_DOSE_SET = new Set<string>();
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
meds,
|
meds,
|
||||||
loading,
|
loading,
|
||||||
@@ -49,9 +95,12 @@ export function DashboardPage() {
|
|||||||
todayDay,
|
todayDay,
|
||||||
futureDays,
|
futureDays,
|
||||||
takenDoses,
|
takenDoses,
|
||||||
|
skippedDoses,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
|
markDoseSkipped,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
|
undoDoseSkipped,
|
||||||
manuallyCollapsedDays,
|
manuallyCollapsedDays,
|
||||||
manuallyExpandedDays,
|
manuallyExpandedDays,
|
||||||
toggleDayCollapse,
|
toggleDayCollapse,
|
||||||
@@ -71,8 +120,158 @@ export function DashboardPage() {
|
|||||||
const [clearingMissed, setClearingMissed] = useState(false);
|
const [clearingMissed, setClearingMissed] = useState(false);
|
||||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const notificationFocusAppliedRef = useRef<string | null>(null);
|
||||||
|
const effectiveSkippedDoses =
|
||||||
|
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
|
||||||
|
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
|
||||||
|
|
||||||
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
const isDoseTakenForDisplay = useCallback((doseId: string) => takenDoses.has(doseId), [takenDoses]);
|
||||||
|
|
||||||
|
const notificationTarget = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const date = params.get("day")?.trim() ?? params.get("date")?.trim() ?? "";
|
||||||
|
const doseId = params.get("dose")?.trim() ?? params.get("doseId")?.trim() ?? "";
|
||||||
|
const medId =
|
||||||
|
params.get("med")?.trim() ?? params.get("medId")?.trim() ?? getMedicationIdFromNotificationDoseId(doseId) ?? "";
|
||||||
|
if (!date && !doseId && !medId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: date || null,
|
||||||
|
doseId: doseId || null,
|
||||||
|
medId: medId || null,
|
||||||
|
key: `${date}|${doseId}|${medId}`,
|
||||||
|
};
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
const targetDayState = useMemo(() => {
|
||||||
|
if (!notificationTarget?.date) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayDateKey = todayDay ? getRouteDateKey(todayDay.date) : null;
|
||||||
|
if (todayDay && todayDateKey === notificationTarget.date) {
|
||||||
|
const allDoseIds = todayDay.meds.flatMap((item) => expandDoseIds(item.doses));
|
||||||
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||||
|
const isAutoCollapsed = allDayTaken;
|
||||||
|
const isManuallyExpanded = manuallyExpandedDays.has(todayDay.dateStr);
|
||||||
|
const isManuallyCollapsed = manuallyCollapsedDays.has(todayDay.dateStr);
|
||||||
|
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||||
|
return { day: todayDay, isAutoCollapsed, isCollapsed, section: "today" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pastDay = pastDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
|
||||||
|
if (pastDay) {
|
||||||
|
const isAutoCollapsed = true;
|
||||||
|
const isCollapsed = !manuallyExpandedDays.has(pastDay.dateStr);
|
||||||
|
return { day: pastDay, isAutoCollapsed, isCollapsed, section: "past" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const futureDay = futureDays.find((day) => getRouteDateKey(day.date) === notificationTarget.date);
|
||||||
|
if (futureDay) {
|
||||||
|
const isAutoCollapsed = true;
|
||||||
|
const isCollapsed = !manuallyExpandedDays.has(futureDay.dateStr);
|
||||||
|
return { day: futureDay, isAutoCollapsed, isCollapsed, section: "future" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
notificationTarget,
|
||||||
|
todayDay,
|
||||||
|
pastDays,
|
||||||
|
futureDays,
|
||||||
|
manuallyExpandedDays,
|
||||||
|
manuallyCollapsedDays,
|
||||||
|
isDoseTakenForDisplay,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notificationTarget || !targetDayState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetDayState.section === "past" && !showPastDays) {
|
||||||
|
setShowPastDays(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDayState.section === "future" && !showFutureDays) {
|
||||||
|
setShowFutureDays(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDayState.isCollapsed) {
|
||||||
|
toggleDayCollapse(targetDayState.day.dateStr, targetDayState.isAutoCollapsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notificationFocusAppliedRef.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
notificationTarget,
|
||||||
|
targetDayState,
|
||||||
|
setShowPastDays,
|
||||||
|
setShowFutureDays,
|
||||||
|
showPastDays,
|
||||||
|
showFutureDays,
|
||||||
|
toggleDayCollapse,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notificationTarget) {
|
||||||
|
notificationFocusAppliedRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || settingsLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDayState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationFocusAppliedRef.current === notificationTarget.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let correctionTimerId: number | null = null;
|
||||||
|
|
||||||
|
const scrollTargetIntoView = () => {
|
||||||
|
try {
|
||||||
|
const targetElement = findFocusTargetElement(notificationTarget.doseId, notificationTarget.medId);
|
||||||
|
|
||||||
|
if (!targetElement || typeof targetElement.scrollIntoView !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameId = requestAnimationFrame(() => {
|
||||||
|
if (!scrollTargetIntoView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
correctionTimerId = window.setTimeout(() => {
|
||||||
|
if (!scrollTargetIntoView()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationFocusAppliedRef.current = notificationTarget.key;
|
||||||
|
}, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
if (correctionTimerId !== null) {
|
||||||
|
window.clearTimeout(correctionTimerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [notificationTarget, targetDayState, loading, settingsLoading]);
|
||||||
|
|
||||||
// Get structured reminder data
|
// Get structured reminder data
|
||||||
const reminderData = getReminderStatusData(
|
const reminderData = getReminderStatusData(
|
||||||
@@ -153,6 +352,63 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderDoseActionButtons = (options: {
|
||||||
|
doseId: string;
|
||||||
|
isTaken: boolean;
|
||||||
|
isSkipped: boolean;
|
||||||
|
isAutomaticallyTaken: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
}) => {
|
||||||
|
const takeButton = options.isTaken ? (
|
||||||
|
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||||
|
{options.isAutomaticallyTaken && (
|
||||||
|
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||||
|
onClick={() => markDoseTaken(options.doseId)}
|
||||||
|
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||||
|
disabled={options.isEmpty || options.isSkipped}
|
||||||
|
>
|
||||||
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
|
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canManageSkippedDoses) {
|
||||||
|
return takeButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipButton = options.isSkipped ? (
|
||||||
|
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||||
|
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||||
|
<span aria-hidden="true">↩</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="dose-btn skip"
|
||||||
|
onClick={() => markDoseSkipped(options.doseId)}
|
||||||
|
title={t("dose.markAsSkipped")}
|
||||||
|
disabled={options.isTaken}
|
||||||
|
>
|
||||||
|
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{takeButton}
|
||||||
|
{skipButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||||
setObsoleteCandidate(med);
|
setObsoleteCandidate(med);
|
||||||
setShowObsoleteConfirm(true);
|
setShowObsoleteConfirm(true);
|
||||||
@@ -708,6 +964,7 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.dateStr}
|
key={day.dateStr}
|
||||||
|
data-date-key={getRouteDateKey(day.date)}
|
||||||
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -753,8 +1010,15 @@ export function DashboardPage() {
|
|||||||
const rowClasses = ["time-row"];
|
const rowClasses = ["time-row"];
|
||||||
if (isEmpty) rowClasses.push("med-empty");
|
if (isEmpty) rowClasses.push("med-empty");
|
||||||
else if (isLowStock) rowClasses.push("med-low");
|
else if (isLowStock) rowClasses.push("med-low");
|
||||||
|
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||||
|
rowClasses.push("notification-focus-target-row");
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={rowClasses.join(" ")}
|
||||||
|
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
@@ -797,7 +1061,7 @@ export function DashboardPage() {
|
|||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
// If no takenBy, show single checkbox; otherwise show one per person
|
// If no takenBy, show single checkbox; otherwise show one per person
|
||||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
const people = getDosePeople(dose.takenBy);
|
||||||
const allTaken = people.every((person) =>
|
const allTaken = people.every((person) =>
|
||||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||||
);
|
);
|
||||||
@@ -828,10 +1092,20 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = isDoseTakenForDisplay(doseId);
|
const isTaken = isDoseTakenForDisplay(doseId);
|
||||||
|
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
|
const personClasses = ["dose-person"];
|
||||||
|
if (isTaken) personClasses.push("taken");
|
||||||
|
if (isSkipped) personClasses.push("skipped");
|
||||||
|
if (notificationTarget?.doseId === doseId)
|
||||||
|
personClasses.push("notification-focus-target");
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={doseId}
|
||||||
|
data-dose-id={doseId}
|
||||||
|
className={personClasses.join(" ")}
|
||||||
|
>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
@@ -843,38 +1117,13 @@ export function DashboardPage() {
|
|||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
|
||||||
<span aria-hidden="true">↩</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
|
||||||
onClick={() => markDoseTaken(doseId)}
|
|
||||||
title={
|
|
||||||
isEmpty
|
|
||||||
? t("common.outOfStockTakeBlocked")
|
|
||||||
: t("dose.markAsTaken")
|
|
||||||
}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1023,6 +1272,7 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.dateStr}
|
key={day.dateStr}
|
||||||
|
data-date-key={getRouteDateKey(day.date)}
|
||||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1067,8 +1317,15 @@ export function DashboardPage() {
|
|||||||
const rowClasses = ["time-row"];
|
const rowClasses = ["time-row"];
|
||||||
if (isEmpty) rowClasses.push("med-empty");
|
if (isEmpty) rowClasses.push("med-empty");
|
||||||
else if (isLowStock) rowClasses.push("med-low");
|
else if (isLowStock) rowClasses.push("med-low");
|
||||||
|
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||||
|
rowClasses.push("notification-focus-target-row");
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={rowClasses.join(" ")}
|
||||||
|
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
@@ -1126,8 +1383,8 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const isOverdue = dose.when < Date.now();
|
const isOverdue = dose.when < Date.now() && !isEmpty;
|
||||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
const people = getDosePeople(dose.takenBy);
|
||||||
const allTaken = people.every((person) =>
|
const allTaken = people.every((person) =>
|
||||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||||
);
|
);
|
||||||
@@ -1159,10 +1416,20 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = isDoseTakenForDisplay(doseId);
|
const isTaken = isDoseTakenForDisplay(doseId);
|
||||||
|
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
|
const personClasses = ["dose-person"];
|
||||||
|
if (isTaken) personClasses.push("taken");
|
||||||
|
if (isSkipped) personClasses.push("skipped");
|
||||||
|
if (notificationTarget?.doseId === doseId)
|
||||||
|
personClasses.push("notification-focus-target");
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={doseId}
|
||||||
|
data-dose-id={doseId}
|
||||||
|
className={personClasses.join(" ")}
|
||||||
|
>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
@@ -1174,38 +1441,13 @@ export function DashboardPage() {
|
|||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
|
||||||
<span aria-hidden="true">↩</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
|
||||||
onClick={() => markDoseTaken(doseId)}
|
|
||||||
title={
|
|
||||||
isEmpty
|
|
||||||
? t("common.outOfStockTakeBlocked")
|
|
||||||
: t("dose.markAsTaken")
|
|
||||||
}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1227,7 +1469,7 @@ export function DashboardPage() {
|
|||||||
const totalFutureDoses = futureDays.flatMap((d) =>
|
const totalFutureDoses = futureDays.flatMap((d) =>
|
||||||
d.meds.flatMap((m) =>
|
d.meds.flatMap((m) =>
|
||||||
m.doses.flatMap((dose) =>
|
m.doses.flatMap((dose) =>
|
||||||
dose.takenBy.length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
|
getDosePeople(dose.takenBy).map((person) => (person ? `${dose.id}-${person}` : dose.id))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1296,6 +1538,7 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.dateStr}
|
key={day.dateStr}
|
||||||
|
data-date-key={getRouteDateKey(day.date)}
|
||||||
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1340,8 +1583,15 @@ export function DashboardPage() {
|
|||||||
const rowClasses = ["time-row"];
|
const rowClasses = ["time-row"];
|
||||||
if (isEmpty) rowClasses.push("med-empty");
|
if (isEmpty) rowClasses.push("med-empty");
|
||||||
else if (isLowStock) rowClasses.push("med-low");
|
else if (isLowStock) rowClasses.push("med-low");
|
||||||
|
if (med?.id != null && notificationTarget?.medId === String(med.id)) {
|
||||||
|
rowClasses.push("notification-focus-target-row");
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`${day.dateStr}-${item.medName}`} className={rowClasses.join(" ")}>
|
<div
|
||||||
|
key={`${day.dateStr}-${item.medName}`}
|
||||||
|
className={rowClasses.join(" ")}
|
||||||
|
data-med-id={med?.id != null ? String(med.id) : undefined}
|
||||||
|
>
|
||||||
<div className="time-main">
|
<div className="time-main">
|
||||||
<div className="med-name">
|
<div className="med-name">
|
||||||
<div
|
<div
|
||||||
@@ -1399,7 +1649,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="doses-col">
|
<div className="doses-col">
|
||||||
{item.doses.map((dose) => {
|
{item.doses.map((dose) => {
|
||||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
const people = getDosePeople(dose.takenBy);
|
||||||
const allTaken = people.every((person) =>
|
const allTaken = people.every((person) =>
|
||||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||||
);
|
);
|
||||||
@@ -1430,10 +1680,20 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = isDoseTakenForDisplay(doseId);
|
const isTaken = isDoseTakenForDisplay(doseId);
|
||||||
|
const isSkipped = effectiveSkippedDoses.has(doseId);
|
||||||
const isAutomaticallyTaken =
|
const isAutomaticallyTaken =
|
||||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
|
const personClasses = ["dose-person"];
|
||||||
|
if (isTaken) personClasses.push("taken");
|
||||||
|
if (isSkipped) personClasses.push("skipped");
|
||||||
|
if (notificationTarget?.doseId === doseId)
|
||||||
|
personClasses.push("notification-focus-target");
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div
|
||||||
|
key={doseId}
|
||||||
|
data-dose-id={doseId}
|
||||||
|
className={personClasses.join(" ")}
|
||||||
|
>
|
||||||
{person && (
|
{person && (
|
||||||
<span
|
<span
|
||||||
className="person-name clickable"
|
className="person-name clickable"
|
||||||
@@ -1445,34 +1705,13 @@ export function DashboardPage() {
|
|||||||
{person}
|
{person}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isTaken ? (
|
{renderDoseActionButtons({
|
||||||
<button
|
doseId,
|
||||||
className="dose-btn undo"
|
isTaken,
|
||||||
onClick={() => undoDoseTaken(doseId)}
|
isSkipped,
|
||||||
title={t("common.undo")}
|
isAutomaticallyTaken,
|
||||||
>
|
isEmpty: true,
|
||||||
{isAutomaticallyTaken && (
|
})}
|
||||||
<span
|
|
||||||
className="info-tooltip"
|
|
||||||
data-tooltip={t("tooltips.automaticTaken")}
|
|
||||||
>
|
|
||||||
🤖
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
|
||||||
<span aria-hidden="true">↩</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={`dose-btn take out-of-stock`}
|
|
||||||
onClick={() => markDoseTaken(doseId)}
|
|
||||||
title={t("common.outOfStockTakeBlocked")}
|
|
||||||
disabled={true}
|
|
||||||
>
|
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
|
||||||
<span aria-hidden="true">⊘</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -257,8 +257,10 @@ export function MedicationsPage() {
|
|||||||
useUnsavedChangesWarning(formChanged);
|
useUnsavedChangesWarning(formChanged);
|
||||||
|
|
||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
||||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
const [pendingEditTransition, setPendingEditTransition] = useState(
|
||||||
|
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
||||||
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
@@ -269,9 +271,23 @@ export function MedicationsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showEditModalRef.current = showEditModal;
|
showEditModalRef.current = showEditModal;
|
||||||
}, [showEditModal]);
|
}, [showEditModal]);
|
||||||
const processedEditMedIdRef = useRef<string | null>(null);
|
const processedMedicationLinkRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
|
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
|
||||||
|
const viewMedId = params.get("viewMedId");
|
||||||
|
if (viewMedId) {
|
||||||
|
return { mode: "view" as const, linkedMedId: viewMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMedId = params.get("editMedId");
|
||||||
|
if (editMedId) {
|
||||||
|
return { mode: "edit" as const, linkedMedId: editMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: null, linkedMedId: null };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync formChanged state to the global context for navigation blocking
|
// Sync formChanged state to the global context for navigation blocking
|
||||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -819,12 +835,13 @@ export function MedicationsPage() {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearEditMedIdParam = useCallback(() => {
|
const clearMedicationLinkParams = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prevParams) => {
|
(prevParams) => {
|
||||||
if (!prevParams.has("editMedId")) return prevParams;
|
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
||||||
const nextParams = new URLSearchParams(prevParams);
|
const nextParams = new URLSearchParams(prevParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
return nextParams;
|
return nextParams;
|
||||||
},
|
},
|
||||||
{ replace: true }
|
{ replace: true }
|
||||||
@@ -848,7 +865,7 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
|
|||||||
if (shouldCloseMobileModal) {
|
if (shouldCloseMobileModal) {
|
||||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
const currentParams = new URLSearchParams(window.location.search);
|
||||||
|
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
}
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
resetMedicationEnrichment();
|
resetMedicationEnrichment();
|
||||||
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
}, [
|
||||||
|
showObsoleteConfirm,
|
||||||
|
showDeleteConfirm,
|
||||||
|
showEditModal,
|
||||||
|
viewMode,
|
||||||
|
formChanged,
|
||||||
|
resetForm,
|
||||||
|
clearMedicationLinkParams,
|
||||||
|
getMedicationLinkState,
|
||||||
|
]);
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
|
|||||||
}, [activeMeds, editingId]);
|
}, [activeMeds, editingId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editMedId = searchParams.get("editMedId");
|
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
||||||
if (!editMedId) {
|
if (!linkedMedId || !linkMode) {
|
||||||
processedEditMedIdRef.current = null;
|
processedMedicationLinkRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (processedEditMedIdRef.current === editMedId) return;
|
const linkKey = `${linkMode}:${linkedMedId}`;
|
||||||
const parsedMedId = Number.parseInt(editMedId, 10);
|
if (processedMedicationLinkRef.current === linkKey) return;
|
||||||
|
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
||||||
if (Number.isNaN(parsedMedId)) return;
|
if (Number.isNaN(parsedMedId)) return;
|
||||||
const medicationToEdit =
|
const medicationToEdit =
|
||||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||||
if (!medicationToEdit) return;
|
if (!medicationToEdit) return;
|
||||||
|
|
||||||
processedEditMedIdRef.current = editMedId;
|
processedMedicationLinkRef.current = linkKey;
|
||||||
|
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(linkMode === "view");
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, ExportModal } from "../components";
|
import { ConfirmModal, ExportModal } from "../components";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
@@ -13,8 +13,11 @@ export function SettingsPage() {
|
|||||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
settings,
|
settings,
|
||||||
|
savedSettings,
|
||||||
setSettings,
|
setSettings,
|
||||||
settingsLoading,
|
settingsLoading,
|
||||||
|
settingsSaving,
|
||||||
|
settingsSaved,
|
||||||
settingsLoadError,
|
settingsLoadError,
|
||||||
// Email testing
|
// Email testing
|
||||||
testEmail,
|
testEmail,
|
||||||
@@ -39,6 +42,8 @@ export function SettingsPage() {
|
|||||||
setImportResult,
|
setImportResult,
|
||||||
meds,
|
meds,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
const [timezoneTouched, setTimezoneTouched] = useState(false);
|
||||||
|
const [timezoneDraft, setTimezoneDraft] = useState("");
|
||||||
|
|
||||||
const hasExistingData = meds.length > 0;
|
const hasExistingData = meds.length > 0;
|
||||||
let emailUnavailableReason: string | null = null;
|
let emailUnavailableReason: string | null = null;
|
||||||
@@ -117,6 +122,49 @@ export function SettingsPage() {
|
|||||||
const automaticStockCalculationId = "settings-stock-calculation-automatic";
|
const automaticStockCalculationId = "settings-stock-calculation-automatic";
|
||||||
const manualStockCalculationId = "settings-stock-calculation-manual";
|
const manualStockCalculationId = "settings-stock-calculation-manual";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimezoneDraft(settings.timezone);
|
||||||
|
}, [settings.timezone]);
|
||||||
|
|
||||||
|
const commitTimezoneDraft = () => {
|
||||||
|
if (timezoneDraft === settings.timezone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimezoneTouched(true);
|
||||||
|
setSettings((prev) => ({ ...prev, timezone: timezoneDraft }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedTimezone = savedSettings?.timezone ?? settings.timezone;
|
||||||
|
const timezoneChanged = settings.timezone !== savedTimezone;
|
||||||
|
const showTimezoneSaving = timezoneTouched && timezoneChanged && settingsSaving;
|
||||||
|
const showTimezoneSaved = timezoneTouched && !timezoneChanged && settingsSaved;
|
||||||
|
let timezoneStatusText = "";
|
||||||
|
if (showTimezoneSaving) {
|
||||||
|
timezoneStatusText = t("settings.timezone.saving");
|
||||||
|
} else if (showTimezoneSaved) {
|
||||||
|
timezoneStatusText = t("settings.timezone.saved");
|
||||||
|
}
|
||||||
|
const timezoneStatusClassName = showTimezoneSaved ? "timezone-status timezone-status-saved" : "timezone-status";
|
||||||
|
const availableTimezones = Array.isArray(settings.availableTimezones) ? settings.availableTimezones : [];
|
||||||
|
const timezoneSuggestions =
|
||||||
|
availableTimezones.length > 0
|
||||||
|
? availableTimezones
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
type IntlWithSupportedValuesOf = typeof Intl & {
|
||||||
|
supportedValuesOf?: (key: string) => string[];
|
||||||
|
};
|
||||||
|
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
|
||||||
|
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
|
||||||
|
return intlWithSupportedValues.supportedValuesOf("timeZone");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return [settings.serverTimezone || "UTC", "UTC"];
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid">
|
<section className="grid">
|
||||||
{settingsLoading ? (
|
{settingsLoading ? (
|
||||||
@@ -160,6 +208,53 @@ export function SettingsPage() {
|
|||||||
<option value="de">🇩🇪 Deutsch</option>
|
<option value="de">🇩🇪 Deutsch</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="setting-row language-row" style={{ marginTop: "12px" }}>
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>{t("settings.timezone.select")}</span>
|
||||||
|
<span className="info-tooltip small tooltip-align-left" data-tooltip={t("settings.timezone.hint")}>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px", width: "auto" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="select-field language-select"
|
||||||
|
value={timezoneDraft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTimezoneDraft(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={commitTimezoneDraft}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
list="settings-timezone-suggestions"
|
||||||
|
placeholder={settings.serverTimezone || "UTC"}
|
||||||
|
/>
|
||||||
|
<datalist id="settings-timezone-suggestions">
|
||||||
|
{timezoneSuggestions.map((zone) => (
|
||||||
|
<option key={zone} value={zone} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setTimezoneTouched(true);
|
||||||
|
setTimezoneDraft("");
|
||||||
|
setSettings((prev) => ({ ...prev, timezone: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settings.timezone.useServerDefault")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={timezoneStatusClassName}>{timezoneStatusText || " "}</p>
|
||||||
|
<p className="hint-text" style={{ marginTop: "8px" }}>
|
||||||
|
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="card" data-testid="settings-notification-card">
|
<article className="card" data-testid="settings-notification-card">
|
||||||
|
|||||||
Vendored
+1
-1
@@ -613,7 +613,7 @@ body.modal-open {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-row {
|
.setting-row {
|
||||||
@@ -311,7 +311,7 @@
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.15s,
|
opacity 0.15s,
|
||||||
visibility 0.15s;
|
visibility 0.15s;
|
||||||
z-index: 1100;
|
z-index: 12000;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
transition:
|
transition:
|
||||||
opacity 0.15s,
|
opacity 0.15s,
|
||||||
visibility 0.15s;
|
visibility 0.15s;
|
||||||
z-index: 1101;
|
z-index: 12001;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
||||||
@@ -507,6 +507,20 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timezone-status {
|
||||||
|
min-height: 1.25rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timezone-status-saved {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
/* Notification Matrix Mobile */
|
/* Notification Matrix Mobile */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.notification-matrix {
|
.notification-matrix {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
|
|
||||||
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../pages", () => ({
|
vi.mock("../pages", () => ({
|
||||||
DashboardPage: () => <div>dashboard-page</div>,
|
DashboardPage: () => {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>dashboard-page</span>
|
||||||
|
<span data-testid="dashboard-location-search">{location.search}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
MedicationsPage: () => <div>medications-page</div>,
|
MedicationsPage: () => <div>medications-page</div>,
|
||||||
PlannerPage: () => <div>planner-page</div>,
|
PlannerPage: () => <div>planner-page</div>,
|
||||||
SchedulePage: () => <div>schedule-page</div>,
|
SchedulePage: () => <div>schedule-page</div>,
|
||||||
@@ -265,6 +273,19 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves notification query params when redirecting root to dashboard", () => {
|
||||||
|
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/${search}`]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders initializing state when auth state is missing", () => {
|
it("renders initializing state when auth state is missing", () => {
|
||||||
authMock = {
|
authMock = {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
|
|||||||
oidcProviderName: "",
|
oidcProviderName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => {
|
|||||||
|
|
||||||
it("shows refill history when expanded", () => {
|
it("shows refill history when expanded", () => {
|
||||||
const refillHistory: RefillEntry[] = [
|
const refillHistory: RefillEntry[] = [
|
||||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
||||||
@@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => {
|
|||||||
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
|
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
|
||||||
const onRefillHistoryExpandedChange = vi.fn();
|
const onRefillHistoryExpandedChange = vi.fn();
|
||||||
const refillHistory: RefillEntry[] = [
|
const refillHistory: RefillEntry[] = [
|
||||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("ReportModal", () => {
|
|||||||
json: async () => ({
|
json: async () => ({
|
||||||
1: {
|
1: {
|
||||||
dosesTaken: 2,
|
dosesTaken: 2,
|
||||||
dosesDismissed: 0,
|
dosesSkipped: 0,
|
||||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||||
refills: [],
|
refills: [],
|
||||||
@@ -74,7 +74,7 @@ describe("ReportModal", () => {
|
|||||||
1: {
|
1: {
|
||||||
dosesTaken: 1,
|
dosesTaken: 1,
|
||||||
automaticDosesTaken: 0,
|
automaticDosesTaken: 0,
|
||||||
dosesDismissed: 0,
|
dosesSkipped: 0,
|
||||||
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
||||||
lastDoseAt: null,
|
lastDoseAt: null,
|
||||||
refills: [],
|
refills: [],
|
||||||
@@ -121,7 +121,7 @@ describe("ReportModal", () => {
|
|||||||
1: {
|
1: {
|
||||||
dosesTaken: 0,
|
dosesTaken: 0,
|
||||||
automaticDosesTaken: 0,
|
automaticDosesTaken: 0,
|
||||||
dosesDismissed: 0,
|
dosesSkipped: 0,
|
||||||
firstDoseAt: null,
|
firstDoseAt: null,
|
||||||
lastDoseAt: null,
|
lastDoseAt: null,
|
||||||
refills: [],
|
refills: [],
|
||||||
@@ -183,13 +183,14 @@ describe("ReportModal", () => {
|
|||||||
1: {
|
1: {
|
||||||
dosesTaken: 1,
|
dosesTaken: 1,
|
||||||
automaticDosesTaken: 0,
|
automaticDosesTaken: 0,
|
||||||
dosesDismissed: 0,
|
dosesSkipped: 0,
|
||||||
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
||||||
lastDoseAt: null,
|
lastDoseAt: null,
|
||||||
refills: [
|
refills: [
|
||||||
{
|
{
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 0,
|
loosePillsAdded: 0,
|
||||||
|
quantityAdded: 20,
|
||||||
usedPrescription: false,
|
usedPrescription: false,
|
||||||
refillDate: "2026-03-04",
|
refillDate: "2026-03-04",
|
||||||
},
|
},
|
||||||
@@ -251,6 +252,81 @@ describe("ReportModal", () => {
|
|||||||
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
|
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the selected person filter with the report request and clears it for all people", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
1: {
|
||||||
|
dosesTaken: 2,
|
||||||
|
automaticDosesTaken: 0,
|
||||||
|
dosesSkipped: 1,
|
||||||
|
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||||
|
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||||
|
refills: [],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
dosesTaken: 1,
|
||||||
|
automaticDosesTaken: 0,
|
||||||
|
dosesSkipped: 0,
|
||||||
|
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||||
|
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||||
|
refills: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRender = render(
|
||||||
|
<ReportModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={onClose}
|
||||||
|
medications={[
|
||||||
|
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||||
|
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/medications/report-data",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
firstRender.unmount();
|
||||||
|
render(
|
||||||
|
<ReportModal
|
||||||
|
isOpen={true}
|
||||||
|
onClose={onClose}
|
||||||
|
medications={[
|
||||||
|
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||||
|
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/medications/report-data",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("generates markdown report and keeps modal open on fetch error", async () => {
|
it("generates markdown report and keeps modal open on fetch error", async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SharedSchedule } from "../../components/SharedSchedule";
|
import { SharedSchedule } from "../../components/SharedSchedule";
|
||||||
@@ -168,10 +168,58 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSharedDoseFetchMock(options: {
|
||||||
|
token?: string;
|
||||||
|
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
|
||||||
|
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
|
||||||
|
}) {
|
||||||
|
const token = options.token ?? "token-123";
|
||||||
|
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
|
||||||
|
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
const body =
|
||||||
|
typeof init?.body === "string" && init.body.length > 0
|
||||||
|
? (JSON.parse(init.body) as { doseId: string })
|
||||||
|
: undefined;
|
||||||
|
requests.push({ url, method, body });
|
||||||
|
|
||||||
|
if (url === `/api/share/${token}` && method === "GET") {
|
||||||
|
return { ok: true, json: async () => options.sharedData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/share/${token}/doses` && method === "GET") {
|
||||||
|
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
|
||||||
|
doseState.set(body.doseId, { doseId: body.doseId, skipped: true });
|
||||||
|
return { ok: true, json: async () => ({}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/share/${token}/doses` && method === "POST" && body?.doseId) {
|
||||||
|
doseState.set(body.doseId, { doseId: body.doseId, takenSource: "manual" });
|
||||||
|
return { ok: true, json: async () => ({}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
|
||||||
|
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||||
|
doseState.delete(doseId);
|
||||||
|
return { ok: true, json: async () => ({}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected request: ${method} ${url}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fetchMock, requests, getDoses: () => Array.from(doseState.values()) };
|
||||||
|
}
|
||||||
|
|
||||||
describe("SharedSchedule", () => {
|
describe("SharedSchedule", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
globalThis.fetch = vi.fn() as unknown as typeof fetch;
|
||||||
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||||
});
|
});
|
||||||
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
|
|||||||
|
|
||||||
it("renders shared schedule shell for valid token", async () => {
|
it("renders shared schedule shell for valid token", async () => {
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
}
|
}
|
||||||
if (url === "/api/share/token-123") {
|
if (url === "/api/share/token-123") {
|
||||||
@@ -247,7 +295,7 @@ describe("SharedSchedule", () => {
|
|||||||
|
|
||||||
it("renders generic error when loading share data fails", async () => {
|
it("renders generic error when loading share data fails", async () => {
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
}
|
}
|
||||||
if (url === "/api/share/token-123") {
|
if (url === "/api/share/token-123") {
|
||||||
@@ -270,7 +318,7 @@ describe("SharedSchedule", () => {
|
|||||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
json: () =>
|
||||||
@@ -296,7 +344,7 @@ describe("SharedSchedule", () => {
|
|||||||
const sharedData = createSharedDataWithEmbeddedOverview();
|
const sharedData = createSharedDataWithEmbeddedOverview();
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
}
|
}
|
||||||
if (url === "/api/share/token-123") {
|
if (url === "/api/share/token-123") {
|
||||||
@@ -318,4 +366,90 @@ describe("SharedSchedule", () => {
|
|||||||
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips a neutral shared dose via the skip endpoint", async () => {
|
||||||
|
const referenceNow = new Date();
|
||||||
|
referenceNow.setHours(12, 0, 0, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||||
|
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||||
|
const { fetchMock, requests } = createSharedDoseFetchMock({ sharedData });
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderSharedSchedule("/share/token-123");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("dose.skip"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: "/api/share/token-123/doses/skip",
|
||||||
|
method: "POST",
|
||||||
|
body: { doseId: sharedData.automaticDoseId },
|
||||||
|
});
|
||||||
|
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes a skipped shared dose via the delete skip endpoint", async () => {
|
||||||
|
const referenceNow = new Date();
|
||||||
|
referenceNow.setHours(12, 0, 0, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||||
|
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||||
|
const { fetchMock, requests } = createSharedDoseFetchMock({
|
||||||
|
sharedData,
|
||||||
|
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderSharedSchedule("/share/token-123");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("dose.undoSkip"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: `/api/share/token-123/doses/skip/${sharedData.automaticDoseId}`,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a skipped shared dose again via the take endpoint", async () => {
|
||||||
|
const referenceNow = new Date();
|
||||||
|
referenceNow.setHours(12, 0, 0, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||||
|
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||||
|
const { fetchMock, requests, getDoses } = createSharedDoseFetchMock({
|
||||||
|
sharedData,
|
||||||
|
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderSharedSchedule("/share/token-123");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("dose.take"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: "/api/share/token-123/doses",
|
||||||
|
method: "POST",
|
||||||
|
body: { doseId: sharedData.automaticDoseId },
|
||||||
|
});
|
||||||
|
expect(getDoses()).toEqual([
|
||||||
|
expect.objectContaining({ doseId: sharedData.automaticDoseId, takenSource: "manual" }),
|
||||||
|
]);
|
||||||
|
expect(document.querySelector(".day-block.today")).toHaveClass("all-taken");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => {
|
|||||||
const sharedData = createSharedData();
|
const sharedData = createSharedData();
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
}
|
}
|
||||||
if (url === "/api/share/token-123") {
|
if (url === "/api/share/token-123") {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../../../features/schedule/formatters";
|
||||||
|
|
||||||
|
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||||
|
switch (key) {
|
||||||
|
case "form.packageAmountUnitMl":
|
||||||
|
return "ml";
|
||||||
|
case "form.blisters.teaspoons":
|
||||||
|
return Number(options?.count) === 1 ? "teaspoon" : "teaspoons";
|
||||||
|
case "form.blisters.tablespoons":
|
||||||
|
return Number(options?.count) === 1 ? "tablespoon" : "tablespoons";
|
||||||
|
case "form.blisters.applications":
|
||||||
|
return Number(options?.count) === 1 ? "application" : "applications";
|
||||||
|
case "common.pill":
|
||||||
|
return "pill";
|
||||||
|
case "common.pills":
|
||||||
|
return "pills";
|
||||||
|
case "common.pillsTotal":
|
||||||
|
return `${options?.count ?? 0} pills total`;
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("schedule formatters", () => {
|
||||||
|
it("formats liquid dose labels in base and converted units", () => {
|
||||||
|
expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 0, t, "ml")).toBe("0 ml");
|
||||||
|
expect(formatScheduleDoseUsageLabel({ packageType: "liquid_container" }, 2, t, "tsp")).toBe("2 teaspoons 10 ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats tube doses as applications by default and ml for liquid forms", () => {
|
||||||
|
expect(formatScheduleDoseUsageLabel({ packageType: "tube" }, 1, t)).toBe("1 application");
|
||||||
|
expect(formatScheduleDoseUsageLabel({ packageType: "tube", medicationForm: "liquid" }, 3, t)).toBe("3 ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats liquid totals from dose units and mixed-unit conversion", () => {
|
||||||
|
expect(
|
||||||
|
formatScheduleTotalUsageLabel(
|
||||||
|
{ packageType: "liquid_container" },
|
||||||
|
0,
|
||||||
|
t,
|
||||||
|
[
|
||||||
|
{ usage: 1, intakeUnit: "tsp" },
|
||||||
|
{ usage: 2, intakeUnit: "tsp" },
|
||||||
|
],
|
||||||
|
"ml"
|
||||||
|
)
|
||||||
|
).toBe("3 teaspoons 15 ml");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatScheduleTotalUsageLabel(
|
||||||
|
{ packageType: "liquid_container" },
|
||||||
|
0,
|
||||||
|
t,
|
||||||
|
[
|
||||||
|
{ usage: 1, intakeUnit: "tsp" },
|
||||||
|
{ usage: 1, intakeUnit: "tbsp" },
|
||||||
|
],
|
||||||
|
"ml"
|
||||||
|
)
|
||||||
|
).toBe("20 ml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to total and non-liquid totals when dose list is not usable", () => {
|
||||||
|
expect(
|
||||||
|
formatScheduleTotalUsageLabel(
|
||||||
|
{ packageType: "liquid_container" },
|
||||||
|
4,
|
||||||
|
t,
|
||||||
|
[{ usage: -1, intakeUnit: "ml" }],
|
||||||
|
"tbsp"
|
||||||
|
)
|
||||||
|
).toBe("4 tablespoons 60 ml");
|
||||||
|
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills total");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
areAllDoseIdsTaken,
|
||||||
|
countTakenDoseIds,
|
||||||
|
resolveCollapsedState,
|
||||||
|
toggleDateInSet,
|
||||||
|
} from "../../../features/schedule/interactions";
|
||||||
|
|
||||||
|
describe("schedule interactions", () => {
|
||||||
|
it("toggles dates without mutating the original set", () => {
|
||||||
|
const previous = new Set(["2026-01-01"]);
|
||||||
|
const added = toggleDateInSet(previous, "2026-01-02");
|
||||||
|
const removed = toggleDateInSet(added, "2026-01-01");
|
||||||
|
|
||||||
|
expect(previous).toEqual(new Set(["2026-01-01"]));
|
||||||
|
expect(added).toEqual(new Set(["2026-01-01", "2026-01-02"]));
|
||||||
|
expect(removed).toEqual(new Set(["2026-01-02"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves auto and manual collapsed states", () => {
|
||||||
|
expect(resolveCollapsedState(true, "2026-01-01", new Set(), new Set())).toBe(true);
|
||||||
|
expect(resolveCollapsedState(true, "2026-01-01", new Set(["2026-01-01"]), new Set())).toBe(false);
|
||||||
|
expect(resolveCollapsedState(false, "2026-01-01", new Set(), new Set(["2026-01-01"]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts and checks taken dose ids", () => {
|
||||||
|
const taken = new Set(["a", "c"]);
|
||||||
|
const isDoseTaken = (doseId: string) => taken.has(doseId);
|
||||||
|
|
||||||
|
expect(countTakenDoseIds(["a", "b", "c"], isDoseTaken)).toBe(2);
|
||||||
|
expect(areAllDoseIdsTaken(["a", "c"], isDoseTaken)).toBe(true);
|
||||||
|
expect(areAllDoseIdsTaken(["a", "b"], isDoseTaken)).toBe(false);
|
||||||
|
expect(areAllDoseIdsTaken([], isDoseTaken)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,9 @@ describe("useRefill", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("loads refill history", async () => {
|
it("loads refill history", async () => {
|
||||||
const mockHistory = [{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: "2024-03-15T10:00:00Z" }];
|
const mockHistory = [
|
||||||
|
{ id: 1, packsAdded: 2, loosePillsAdded: 0, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -49,7 +51,7 @@ describe("useRefill", () => {
|
|||||||
|
|
||||||
it("handles refill history with refills wrapper", async () => {
|
it("handles refill history with refills wrapper", async () => {
|
||||||
const mockHistory = {
|
const mockHistory = {
|
||||||
refills: [{ id: 1, packsAdded: 2, createdAt: "2024-03-15T10:00:00Z" }],
|
refills: [{ id: 1, packsAdded: 2, quantityAdded: 20, createdAt: "2024-03-15T10:00:00Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
@@ -162,7 +164,7 @@ describe("useRefill", () => {
|
|||||||
"/api/medications/1/refill",
|
"/api/medications/1/refill",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, usePrescription: false }),
|
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0, quantityAdded: 0, usePrescription: false }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(fetch).toHaveBeenNthCalledWith(
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
@@ -505,6 +507,53 @@ describe("useRefill", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps liquid stock correction base fields aligned", async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const liquidMed: Medication = {
|
||||||
|
id: 12,
|
||||||
|
name: "Aligned Liquid",
|
||||||
|
medicationForm: "liquid",
|
||||||
|
packageType: "liquid_container",
|
||||||
|
doseUnit: "ml",
|
||||||
|
packCount: 1,
|
||||||
|
packageAmountValue: 180,
|
||||||
|
packageAmountUnit: "ml",
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 180,
|
||||||
|
looseTablets: 180,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoadMeds = vi.fn();
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(liquidMed, {
|
||||||
|
all: [{ name: liquidMed.name, medsLeft: 180, daysLeft: 36 }] as Coverage[],
|
||||||
|
});
|
||||||
|
result.current.setEditStockFullBlisters(2);
|
||||||
|
result.current.setEditStockPartialBlisterPills(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(12, liquidMed, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = JSON.parse(requestInit.body as string);
|
||||||
|
expect(body).toEqual({
|
||||||
|
stockAdjustment: -60,
|
||||||
|
packCount: 2,
|
||||||
|
totalPills: 360,
|
||||||
|
looseTablets: 360,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
|
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ const mockTodayDay = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getRouteDateKey(value: Date): string {
|
||||||
|
const year = value.getFullYear();
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Default mock factory
|
// Default mock factory
|
||||||
const createMockAppContext = (overrides = {}) => ({
|
const createMockAppContext = (overrides = {}) => ({
|
||||||
meds: [],
|
meds: [],
|
||||||
@@ -158,6 +165,7 @@ const createMockAppContext = (overrides = {}) => ({
|
|||||||
todayDay: null,
|
todayDay: null,
|
||||||
futureDays: [],
|
futureDays: [],
|
||||||
takenDoses: new Set(),
|
takenDoses: new Set(),
|
||||||
|
skippedDoses: new Set(),
|
||||||
dismissedDoses: new Set(),
|
dismissedDoses: new Set(),
|
||||||
markDoseTaken: vi.fn(),
|
markDoseTaken: vi.fn(),
|
||||||
undoDoseTaken: vi.fn(),
|
undoDoseTaken: vi.fn(),
|
||||||
@@ -321,6 +329,7 @@ describe("DashboardPage", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mockContextValue = createMockAppContext();
|
mockContextValue = createMockAppContext();
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders dashboard page", () => {
|
it("renders dashboard page", () => {
|
||||||
@@ -377,6 +386,41 @@ describe("DashboardPage", () => {
|
|||||||
expect(cards.length).toBeGreaterThan(0);
|
expect(cards.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders today doses even when schedule data omits takenBy arrays", () => {
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
todayDay: {
|
||||||
|
dateStr: "Today",
|
||||||
|
date: new Date(),
|
||||||
|
isPast: false,
|
||||||
|
meds: [
|
||||||
|
{
|
||||||
|
medName: "Aspirin",
|
||||||
|
total: 1,
|
||||||
|
doses: [
|
||||||
|
{
|
||||||
|
id: "dose-without-taken-by",
|
||||||
|
timeStr: "09:00",
|
||||||
|
when: Date.now() + 60_000,
|
||||||
|
usage: 1,
|
||||||
|
takenBy: undefined as unknown as string[],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastWhen: Date.now() + 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("09:00")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders schedule days selector", () => {
|
it("renders schedule days selector", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -505,6 +549,7 @@ describe("DashboardPage interactions", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mockContextValue = createMockAppContext();
|
mockContextValue = createMockAppContext();
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has schedule days options", () => {
|
it("has schedule days options", () => {
|
||||||
@@ -539,6 +584,138 @@ describe("DashboardPage interactions", () => {
|
|||||||
expect(setScheduleDays).toHaveBeenCalledWith(90);
|
expect(setScheduleDays).toHaveBeenCalledWith(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders today doses when skip state is missing from an older app context shape", () => {
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
meds: mockMeds,
|
||||||
|
coverage: mockCoverage,
|
||||||
|
todayDay: mockTodayDay,
|
||||||
|
skippedDoses: undefined,
|
||||||
|
markDoseSkipped: undefined,
|
||||||
|
undoDoseSkipped: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".day-block.today .dose-btn.take")).toBeInTheDocument();
|
||||||
|
expect(document.querySelector(".day-block.today .dose-btn.skip")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the dashboard rendered when notification focus scrolling fails", async () => {
|
||||||
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn(() => {
|
||||||
|
throw new Error("scroll failed");
|
||||||
|
});
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
meds: mockMeds,
|
||||||
|
coverage: mockCoverage,
|
||||||
|
todayDay: mockTodayDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||||
|
>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Today")).toBeInTheDocument();
|
||||||
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||||
|
expect(targetDose).toHaveClass("notification-focus-target");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights and scrolls to the notification-linked dashboard dose", async () => {
|
||||||
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
meds: mockMeds,
|
||||||
|
coverage: mockCoverage,
|
||||||
|
todayDay: mockTodayDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||||
|
>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||||
|
const targetRow = document.querySelector('[data-med-id="1"]');
|
||||||
|
expect(targetDose).toHaveClass("notification-focus-target");
|
||||||
|
expect(targetRow).toHaveClass("notification-focus-target-row");
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports the shorter dashboard notification query params", async () => {
|
||||||
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
meds: mockMeds,
|
||||||
|
coverage: mockCoverage,
|
||||||
|
todayDay: mockTodayDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/dashboard?day=${getRouteDateKey(mockTodayDay.date)}&dose=${encodeURIComponent(doseId)}`]}
|
||||||
|
>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||||
|
const targetRow = document.querySelector('[data-med-id="1"]');
|
||||||
|
expect(targetDose).toHaveClass("notification-focus-target");
|
||||||
|
expect(targetRow).toHaveClass("notification-focus-target-row");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls to the notification-linked dashboard dose after schedule data loads", async () => {
|
||||||
|
const doseId = String(mockTodayDay.meds[0].doses[0].id);
|
||||||
|
mockContextValue = createMockAppContext();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||||
|
>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.querySelector(`[data-dose-id="${doseId}"]`)).toBeNull();
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mockContextValue = createMockAppContext({
|
||||||
|
meds: mockMeds,
|
||||||
|
coverage: mockCoverage,
|
||||||
|
todayDay: mockTodayDay,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[`/?date=${getRouteDateKey(mockTodayDay.date)}&medId=1&doseId=${encodeURIComponent(doseId)}`]}
|
||||||
|
>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const targetDose = document.querySelector(`[data-dose-id="${doseId}"]`);
|
||||||
|
expect(targetDose).toHaveClass("notification-focus-target");
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("hides past and future sections when upcomingTodayOnly is enabled", () => {
|
it("hides past and future sections when upcomingTodayOnly is enabled", () => {
|
||||||
mockContextValue = createMockAppContext({
|
mockContextValue = createMockAppContext({
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens read-only view from viewMedId query parameter", async () => {
|
||||||
|
const startEdit = vi.fn();
|
||||||
|
mockFormHookValue = createMockFormHook({ startEdit });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
|
||||||
|
|
||||||
|
renderPage("/medications?viewMedId=1");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(startEdit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("common.close")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
||||||
const startEdit = vi.fn();
|
const startEdit = vi.fn();
|
||||||
const resetForm = vi.fn();
|
const resetForm = vi.fn();
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ describe("getMedTotal", () => {
|
|||||||
expect(getMedTotal(tube)).toBe(604);
|
expect(getMedTotal(tube)).toBe(604);
|
||||||
expect(getMedTotal(liquid)).toBe(450);
|
expect(getMedTotal(liquid)).toBe(450);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers canonical amount-base stock over compatibility mirror fields", () => {
|
||||||
|
const liquid = {
|
||||||
|
packageType: "liquid_container" as const,
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 300,
|
||||||
|
looseTablets: 150,
|
||||||
|
stockAdjustment: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getMedTotal(liquid)).toBe(150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPackageSize", () => {
|
describe("getPackageSize", () => {
|
||||||
@@ -200,7 +214,7 @@ describe("getPackageSize", () => {
|
|||||||
expect(getPackageSize(med)).toBe(80);
|
expect(getPackageSize(med)).toBe(80);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns totalPills for tube/liquid container package size", () => {
|
it("returns canonical amount-base stock for tube/liquid container package size", () => {
|
||||||
const tube = {
|
const tube = {
|
||||||
packageType: "tube" as const,
|
packageType: "tube" as const,
|
||||||
packCount: 4,
|
packCount: 4,
|
||||||
@@ -221,6 +235,19 @@ describe("getPackageSize", () => {
|
|||||||
expect(getPackageSize(tube)).toBe(600);
|
expect(getPackageSize(tube)).toBe(600);
|
||||||
expect(getPackageSize(liquid)).toBe(450);
|
expect(getPackageSize(liquid)).toBe(450);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers canonical amount-base stock for package size when compatibility mirror drifts", () => {
|
||||||
|
const tube = {
|
||||||
|
packageType: "tube" as const,
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
totalPills: 300,
|
||||||
|
looseTablets: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getPackageSize(tube)).toBe(150);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getStockDisplayCapacity", () => {
|
describe("getStockDisplayCapacity", () => {
|
||||||
|
|||||||
@@ -1264,14 +1264,14 @@ describe("getStockStatus", () => {
|
|||||||
expect(result.className).toBe("danger");
|
expect(result.className).toBe("danger");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns out-of-stock when daysLeft is 0", () => {
|
it("returns critical when daysLeft is 0 but stock remains", () => {
|
||||||
const result = getStockStatus(0, 5, thresholds);
|
const result = getStockStatus(0, 5, thresholds);
|
||||||
expect(result.level).toBe("out-of-stock");
|
expect(result.level).toBe("critical");
|
||||||
expect(result.className).toBe("danger");
|
expect(result.className).toBe("danger");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns high when daysLeft > highStockDays", () => {
|
it("returns high when daysLeft > highStockDays", () => {
|
||||||
const result = getStockStatus(200, 100, thresholds);
|
const result = getStockStatus(181, 100, thresholds);
|
||||||
expect(result.level).toBe("high");
|
expect(result.level).toBe("high");
|
||||||
expect(result.className).toBe("high");
|
expect(result.className).toBe("high");
|
||||||
});
|
});
|
||||||
@@ -1377,9 +1377,9 @@ describe("getStockStatus", () => {
|
|||||||
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
|
const resultCritical = getStockStatus(1, 100, boundaryThresholds, "liquid_container");
|
||||||
expect(resultCritical.level).toBe("critical");
|
expect(resultCritical.level).toBe("critical");
|
||||||
|
|
||||||
// daysLeft = 0 (out of stock)
|
// daysLeft = 0 with stock remaining is still critical, not empty
|
||||||
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
|
const resultEmpty = getStockStatus(0, 100, boundaryThresholds, "liquid_container");
|
||||||
expect(resultEmpty.level).toBe("out-of-stock");
|
expect(resultEmpty.level).toBe("critical");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ export type PlannerRow = {
|
|||||||
export type RefillEntry = {
|
export type RefillEntry = {
|
||||||
id: number;
|
id: number;
|
||||||
packsAdded: number;
|
packsAdded: number;
|
||||||
loosePillsAdded: number;
|
loosePillsAdded?: number;
|
||||||
|
quantityAdded: number;
|
||||||
usedPrescription?: boolean;
|
usedPrescription?: boolean;
|
||||||
refillDate: string;
|
refillDate: string;
|
||||||
};
|
};
|
||||||
@@ -409,10 +410,11 @@ export function getMedTotal(med: MedLike): number {
|
|||||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
return med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Amount-based package types store their current base stock directly
|
// Amount-based package types use the same canonical base field as the backend:
|
||||||
// in totalPills (fallback looseTablets for legacy rows).
|
// looseTablets stores the current amount baseline, while totalPills is kept in sync
|
||||||
|
// for compatibility and UI helpers.
|
||||||
if (isAmountBasedPackageType(med.packageType)) {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
const baseStock = med.totalPills ?? med.looseTablets;
|
const baseStock = med.looseTablets ?? med.totalPills ?? 0;
|
||||||
return baseStock + (med.stockAdjustment ?? 0);
|
return baseStock + (med.stockAdjustment ?? 0);
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
@@ -425,9 +427,9 @@ export function getPackageSize(med: MedLike): number {
|
|||||||
return med.totalPills ?? med.looseTablets;
|
return med.totalPills ?? med.looseTablets;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Amount-based package types use totalPills as base capacity
|
// Amount-based package types reuse the backend canonical amount baseline.
|
||||||
if (isAmountBasedPackageType(med.packageType)) {
|
if (isAmountBasedPackageType(med.packageType)) {
|
||||||
return med.totalPills ?? med.looseTablets;
|
return med.looseTablets ?? med.totalPills ?? 0;
|
||||||
}
|
}
|
||||||
// For blister type, calculate from packs + loose
|
// For blister type, calculate from packs + loose
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||||
|
|||||||
@@ -293,8 +293,8 @@ export function getStockStatus(
|
|||||||
thresholds: StockThresholds,
|
thresholds: StockThresholds,
|
||||||
packageType?: PackageType
|
packageType?: PackageType
|
||||||
): StockStatus {
|
): StockStatus {
|
||||||
// Out of stock or completely depleted = danger (red)
|
// Only a real zero-or-below stock count is out of stock.
|
||||||
if (medsLeft <= 0 || daysLeft === 0) {
|
if (medsLeft <= 0) {
|
||||||
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { existsSync, readFileSync } from "fs";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
function parseCsvEnv(value: string | undefined, fallback: string[]) {
|
||||||
|
const entries = value
|
||||||
|
?.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
return entries && entries.length > 0 ? entries : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPort(value: string | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Read version from package.json at build time
|
// Read version from package.json at build time
|
||||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
@@ -9,6 +27,19 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
|||||||
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
||||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||||
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
||||||
|
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
|
||||||
|
const hmrHost = process.env.VITE_HMR_HOST?.trim();
|
||||||
|
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
|
||||||
|
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
|
||||||
|
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
|
||||||
|
const hmr = hmrHost
|
||||||
|
? {
|
||||||
|
host: hmrHost,
|
||||||
|
protocol: hmrProtocol ?? "wss",
|
||||||
|
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
|
||||||
|
port: hmrPort ?? 5173,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -19,6 +50,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
allowedHosts,
|
||||||
|
hmr,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: backendTarget,
|
target: backendTarget,
|
||||||
|
|||||||
Generated
+36
-36
@@ -6,7 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng",
|
"name": "medassist-ng",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.4.0"
|
"lint-staged": "^16.4.0"
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/biome": {
|
"node_modules/@biomejs/biome": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||||
"integrity": "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==",
|
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -92,20 +92,20 @@
|
|||||||
"url": "https://opencollective.com/biome"
|
"url": "https://opencollective.com/biome"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@biomejs/cli-darwin-arm64": "2.4.10",
|
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.10",
|
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.10",
|
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.10",
|
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||||
"@biomejs/cli-linux-x64": "2.4.10",
|
"@biomejs/cli-linux-x64": "2.4.14",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.10",
|
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.10",
|
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||||
"@biomejs/cli-win32-x64": "2.4.10"
|
"@biomejs/cli-win32-x64": "2.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==",
|
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -120,9 +120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-x64": {
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==",
|
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -137,9 +137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64": {
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==",
|
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -154,9 +154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||||
"integrity": "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==",
|
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -171,9 +171,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64": {
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==",
|
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -188,9 +188,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||||
"integrity": "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==",
|
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -205,9 +205,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-arm64": {
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==",
|
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -222,9 +222,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-win32-x64": {
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
"version": "2.4.10",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==",
|
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"husky": "^9.1.0",
|
"husky": "^9.1.0",
|
||||||
"lint-staged": "^16.4.0"
|
"lint-staged": "^16.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user