Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e85b29549 | |||
| e55e695c88 | |||
| 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 | |||
| d5b3c5c21f | |||
| 002f16c505 | |||
| aa050f7dc5 | |||
| 0795bfe589 | |||
| 25483c12f0 | |||
| 2a340855fb | |||
| 52fec1a4e5 | |||
| 1cb4a44cef | |||
| 51b09dc563 | |||
| dbbd9d5ed8 | |||
| 15f1e33aa4 |
+2
-1
@@ -37,7 +37,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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
+12
-2
@@ -83,18 +83,28 @@ 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/
|
||||||
docs/SPEC_KIT.md
|
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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_settings` ADD `timezone` text 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
+377
-1427
File diff suppressed because it is too large
Load Diff
+21
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.22.1",
|
"version": "1.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -20,35 +20,40 @@
|
|||||||
"@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.3.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.4",
|
"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.9",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@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": {
|
||||||
|
"@esbuild-kit/esm-loader": "2.6.5",
|
||||||
|
"@esbuild-kit/core-utils": "3.3.2",
|
||||||
|
"esbuild": "0.25.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'`,
|
||||||
|
|||||||
@@ -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,6 +105,7 @@ 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
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const EnvSchema = z.object({
|
|||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("3000"),
|
.default(3000),
|
||||||
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"),
|
||||||
OPENAPI_DOCS_ENABLED: z
|
OPENAPI_DOCS_ENABLED: z
|
||||||
@@ -26,17 +26,17 @@ const EnvSchema = z.object({
|
|||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
// 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")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
// 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")
|
.transform((v) => v === "true")
|
||||||
.default("true"),
|
.default(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(),
|
||||||
@@ -47,11 +47,11 @@ const EnvSchema = z.object({
|
|||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("15"),
|
.default(15),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("7"),
|
.default(7),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||||
@@ -59,7 +59,7 @@ const EnvSchema = z.object({
|
|||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
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(),
|
||||||
@@ -68,7 +68,7 @@ const EnvSchema = z.object({
|
|||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("true"),
|
.default(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,37 +109,44 @@ 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(),
|
emailStockReminders: z.boolean().default(true),
|
||||||
emailStockReminders: z.boolean().default(true),
|
emailIntakeReminders: z.boolean().default(true),
|
||||||
emailIntakeReminders: z.boolean().default(true),
|
emailPrescriptionReminders: z.boolean().default(true),
|
||||||
emailPrescriptionReminders: z.boolean().default(true),
|
// Push notifications
|
||||||
// Push notifications
|
shoutrrrEnabled: z.boolean().optional(),
|
||||||
shoutrrrEnabled: z.boolean().optional(),
|
shoutrrrUrl: z.string().nullable().optional(),
|
||||||
shoutrrrUrl: z.string().nullable().optional(),
|
shoutrrrStockReminders: z.boolean().default(true),
|
||||||
shoutrrrStockReminders: z.boolean().default(true),
|
shoutrrrIntakeReminders: z.boolean().default(true),
|
||||||
shoutrrrIntakeReminders: z.boolean().default(true),
|
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
||||||
shoutrrrPrescriptionReminders: z.boolean().default(true),
|
// Reminder settings
|
||||||
// Reminder settings
|
reminderDaysBefore: z.number().int().default(7),
|
||||||
reminderDaysBefore: z.number().int().default(7),
|
repeatDailyReminders: z.boolean().default(false),
|
||||||
repeatDailyReminders: z.boolean().default(false),
|
skipRemindersForTakenDoses: z.boolean().default(false),
|
||||||
skipRemindersForTakenDoses: z.boolean().default(false),
|
repeatRemindersEnabled: z.boolean().default(false),
|
||||||
repeatRemindersEnabled: z.boolean().default(false),
|
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
||||||
reminderRepeatIntervalMinutes: z.number().int().default(30),
|
maxNaggingReminders: z.number().int().default(5),
|
||||||
maxNaggingReminders: z.number().int().default(5),
|
// Stock thresholds
|
||||||
// Stock thresholds
|
lowStockDays: z.number().int().default(30),
|
||||||
lowStockDays: z.number().int().default(30),
|
normalStockDays: z.number().int().default(90),
|
||||||
normalStockDays: z.number().int().default(90),
|
highStockDays: z.number().int().default(180),
|
||||||
highStockDays: z.number().int().default(180),
|
expiryWarningDays: z.number().int().default(90),
|
||||||
expiryWarningDays: z.number().int().default(90),
|
// UI preferences
|
||||||
// UI preferences
|
language: z.string().default("en"),
|
||||||
language: z.string().default("en"),
|
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
||||||
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
|
shareMedicationOverview: z.boolean().default(false),
|
||||||
shareStockStatus: z.boolean().default(true),
|
});
|
||||||
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);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { medications } from "../db/schema.js";
|
import { medications } from "../db/schema.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
type StockReminderItem as SharedStockReminderItem,
|
type StockReminderItem as SharedStockReminderItem,
|
||||||
} from "../services/notifications/builders.js";
|
} from "../services/notifications/builders.js";
|
||||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
|
||||||
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
@@ -428,19 +427,9 @@ ${getFooterPlain(language)}`;
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
from: smtpFrom,
|
||||||
to: email,
|
to: email,
|
||||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||||
@@ -448,9 +437,8 @@ ${getFooterPlain(language)}`;
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Failed to send demand email");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
|
|||||||
@@ -18,10 +18,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 +30,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 +52,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 +84,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 +141,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 +159,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 +174,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 +194,31 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update medication stock
|
const refillBaselineAt = new Date();
|
||||||
const newPackCount = med.packCount + effectivePacksAdded;
|
const baselineStockBeforeRefill = isAmountBased
|
||||||
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
const previousAmountBase = med.totalPills ?? med.looseTablets;
|
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
const newTotalAmount = previousAmountBase + effectiveLoosePillsAdded;
|
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||||
|
|
||||||
|
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||||
|
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||||
|
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 +228,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 +240,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 +268,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 +337,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: {
|
||||||
@@ -93,7 +109,10 @@ 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 }).from(medications).where(eq(medications.userId, userId));
|
||||||
@@ -122,6 +141,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,7 +156,7 @@ 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; usedPrescription: boolean; refillDate: string }[];
|
||||||
@@ -147,7 +167,7 @@ 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);
|
||||||
|
|
||||||
@@ -160,7 +180,7 @@ 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) => ({
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { userSettings } from "../db/schema.js";
|
import { userSettings } from "../db/schema.js";
|
||||||
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 { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
||||||
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,
|
||||||
@@ -445,49 +454,34 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { email } = request.body;
|
const { email } = request.body;
|
||||||
|
|
||||||
const smtpHost = process.env.SMTP_HOST;
|
const smtp = getSmtpConfig();
|
||||||
const smtpUser = process.env.SMTP_USER;
|
|
||||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
|
||||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
|
||||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
|
||||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
hasSmtpHost: Boolean(smtpHost),
|
hasSmtpHost: Boolean(smtp.host),
|
||||||
hasSmtpUser: Boolean(smtpUser),
|
hasSmtpUser: Boolean(smtp.user),
|
||||||
hasSmtpPass: Boolean(smtpPass),
|
hasSmtpPass: Boolean(smtp.pass),
|
||||||
hasSmtpFrom: Boolean(smtpFrom),
|
hasSmtpFrom: Boolean(smtp.from),
|
||||||
smtpPort,
|
smtpPort: smtp.port,
|
||||||
smtpSecure,
|
smtpSecure: smtp.secure,
|
||||||
},
|
},
|
||||||
"[Settings] Test email request received"
|
"[Settings] Test email request received"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!smtpHost || !smtpUser) {
|
if (!smtp.host || !smtp.user) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
{ to: email, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user) },
|
||||||
"[Settings] Test email skipped: SMTP not configured"
|
"[Settings] Test email skipped: SMTP not configured"
|
||||||
);
|
);
|
||||||
return reply.status(400).send({ error: "SMTP not configured" });
|
return reply.status(400).send({ error: "SMTP not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: smtpHost,
|
|
||||||
port: smtpPort,
|
|
||||||
secure: smtpSecure,
|
|
||||||
auth: {
|
|
||||||
user: smtpUser,
|
|
||||||
pass: smtpPass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
request.log.info({ to: email }, "[Settings] Sending test email");
|
request.log.info({ to: email }, "[Settings] Sending test email");
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await sendEmailNotification({
|
||||||
from: smtpFrom,
|
from: smtp.from,
|
||||||
to: email,
|
to: email,
|
||||||
subject: "MedAssist-ng - Test Email",
|
subject: "MedAssist-ng - Test Email",
|
||||||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||||||
@@ -502,9 +496,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliveryError = getDeliveryError(mailResult);
|
if (!mailResult.success) {
|
||||||
if (deliveryError) {
|
throw new Error(mailResult.error ?? "Failed to send test email");
|
||||||
throw new Error(deliveryError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ export function getSmtpConfig(): {
|
|||||||
return { host, user, pass, port, secure, from };
|
return { host, user, pass, port, secure, from };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createSmtpTransport(smtp = getSmtpConfig()) {
|
||||||
|
if (!smtp.host || !smtp.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SMTP endpoint is configured by the server operator via environment variables,
|
||||||
|
// not derived from request-controlled input.
|
||||||
|
// lgtm [js/request-forgery]
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: smtp.host,
|
||||||
|
port: smtp.port,
|
||||||
|
secure: smtp.secure,
|
||||||
|
auth: {
|
||||||
|
user: smtp.user,
|
||||||
|
pass: smtp.pass ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
|
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
|
||||||
const smtp = getSmtpConfig();
|
const smtp = getSmtpConfig();
|
||||||
if (!smtp.host || !smtp.user) {
|
if (!smtp.host || !smtp.user) {
|
||||||
@@ -71,15 +90,10 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createSmtpTransport(smtp);
|
||||||
host: smtp.host,
|
if (!transporter) {
|
||||||
port: smtp.port,
|
return { success: false, error: "SMTP not configured" };
|
||||||
secure: smtp.secure,
|
}
|
||||||
auth: {
|
|
||||||
user: smtp.user,
|
|
||||||
pass: smtp.pass ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mailResult = await transporter.sendMail({
|
const mailResult = await transporter.sendMail({
|
||||||
from: input.from ?? smtp.from,
|
from: input.from ?? smtp.from,
|
||||||
|
|||||||
@@ -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,7 +338,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
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);
|
||||||
@@ -400,7 +401,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
describe("Real /doses/taken routes", () => {
|
describe("Real /doses/taken routes", () => {
|
||||||
it("should mark a dose using real route", async () => {
|
it("should mark a dose using real route", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const medicationId = await createMedication(testClient, userId, "Dose Route Med", []);
|
||||||
|
const doseId = `${medicationId}-0-1735344000000`;
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1118,7 +1120,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
describe("Real /doses/taken routes - edge cases", () => {
|
describe("Real /doses/taken routes - edge cases", () => {
|
||||||
it("should return already marked message for duplicate dose", async () => {
|
it("should return already marked message for duplicate dose", async () => {
|
||||||
const doseId = "1-0-1735344000000";
|
const medicationId = await createMedication(testClient, userId, "Duplicate Dose Med", []);
|
||||||
|
const doseId = `${medicationId}-0-1735344000000`;
|
||||||
|
|
||||||
// Mark first time
|
// Mark first time
|
||||||
await app.inject({
|
await app.inject({
|
||||||
@@ -1139,7 +1142,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle doses with person name in doseId", async () => {
|
it("should handle doses with person name in doseId", async () => {
|
||||||
const doseId = "1-0-1735344000000-Daniel";
|
const medicationId = await createMedication(testClient, userId, "Taken By Med", ["Daniel"]);
|
||||||
|
const doseId = `${medicationId}-0-1735344000000-Daniel`;
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1351,7 +1355,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle dose marking and get taken doses", async () => {
|
it("should handle dose marking and get taken doses", async () => {
|
||||||
const doseId = "99-0-1735344000099";
|
const medicationId = await createMedication(testClient, userId, "Coverage Dose Med", []);
|
||||||
|
const doseId = `${medicationId}-0-1735344000099`;
|
||||||
|
|
||||||
// Mark the dose
|
// Mark the dose
|
||||||
const markResponse = await app.inject({
|
const markResponse = await app.inject({
|
||||||
|
|||||||
@@ -11,32 +11,32 @@ const EnvSchema = z.object({
|
|||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("3000"),
|
.default(3000),
|
||||||
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"),
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
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))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("15"),
|
.default(15),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => parseInt(v, 10))
|
.transform((v) => parseInt(v, 10))
|
||||||
.default("7"),
|
.default(7),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("false"),
|
.default(false),
|
||||||
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(),
|
||||||
@@ -45,7 +45,7 @@ const EnvSchema = z.object({
|
|||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default("true"),
|
.default(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"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ Purpose: persistent agent work memory to survive context loss.
|
|||||||
|
|
||||||
## Entries
|
## Entries
|
||||||
|
|
||||||
|
### 2026-04-10
|
||||||
|
|
||||||
|
- Task: Investigate and fix the production blank-homepage bug (user report: both containers running, blank page, many `400 - -` log lines in frontend container).
|
||||||
|
- Root cause: `upgrade-insecure-requests` directive was present in the `Content-Security-Policy` header in `frontend/nginx.conf`. This directive instructs browsers to upgrade all same-host HTTP requests to HTTPS (preserving the port). When users access the app over plain HTTP (e.g., `http://host:4174/`), the browser receives this CSP and upgrades subsequent asset requests (`/assets/index-*.js`, `/assets/index-*.css`, favicons, API calls) to `https://host:4174/...`. The nginx container only speaks plain HTTP on port 4174, so it receives TLS Client Hello bytes which it cannot parse as an HTTP request. nginx returns `400 Bad Request` with no parseable method or URI — producing the `400 - -` log pattern. All JS/CSS bundles fail to load, React never mounts, and the page stays blank.
|
||||||
|
- Fix: Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf` (line 20). No other changes needed.
|
||||||
|
- Validation notes: The directive is safe to remove — `upgrade-insecure-requests` is designed for HTTPS-only sites and is harmful when the server runs on plain HTTP. Removing it does not weaken security for self-hosted HTTP deployments (mixed content is not a concern when the origin itself is HTTP). If a reverse proxy with TLS termination is added in front, the directive can be re-introduced at the proxy level.
|
||||||
|
- Files touched: `frontend/nginx.conf`.
|
||||||
|
|
||||||
### 2026-03-25
|
### 2026-03-25
|
||||||
|
|
||||||
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
|
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## Entries
|
## Entries
|
||||||
|
|
||||||
|
### 2026-04-10
|
||||||
|
- Scope: Investigate and fix the production blank-homepage bug.
|
||||||
|
- Root cause: The `Content-Security-Policy` header in `frontend/nginx.conf` included the `upgrade-insecure-requests` directive. This directive instructs browsers to upgrade all HTTP resource requests to HTTPS (same port). In a plain HTTP deployment (the default Docker setup on port 4174), this causes the browser to attempt TLS connections to the nginx HTTP port. nginx cannot parse the TLS bytes as HTTP and returns `400 Bad Request` with no method/URI — the `400 - -` log pattern the user observed. All JS/CSS bundles fail to load; React never mounts; the page stays blank.
|
||||||
|
- What changed:
|
||||||
|
- Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf`.
|
||||||
|
- Validation:
|
||||||
|
- `upgrade-insecure-requests` is designed for HTTPS-only sites. Removing it from a plain HTTP server is correct and does not reduce security.
|
||||||
|
- After this fix, browsers accessing the app over HTTP will load assets normally without being redirected to a non-existent HTTPS endpoint.
|
||||||
|
- If TLS termination is added via a reverse proxy in future, the directive can be applied at the proxy layer.
|
||||||
|
- Result: The blank-homepage bug is fixed. All asset and API requests now succeed over plain HTTP as expected.
|
||||||
|
|
||||||
### 2026-03-25
|
### 2026-03-25
|
||||||
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
|
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
|
||||||
- What changed:
|
- What changed:
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||||
|
|
||||||
# Allow larger file uploads (for medication images and data import/export)
|
# Allow larger file uploads (for medication images and data import/export)
|
||||||
|
|||||||
Generated
+311
-297
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.1",
|
"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.1",
|
"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.1",
|
"react-i18next": "^17.0.6",
|
||||||
"react-router-dom": "^7.13.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.9",
|
"@biomejs/biome": "^2.4.14",
|
||||||
"@playwright/test": "^1.58.2",
|
"@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.3.5",
|
"@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.3",
|
"vite": "^8.0.10",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
import { formatNumber, generateICS, getExpiryClass, getSystemLocale, withFormattingTimezone } from "../utils";
|
||||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
@@ -1092,23 +1092,26 @@ export function MedDetailModal({
|
|||||||
{refillHistory.map((entry) => (
|
{refillHistory.map((entry) => (
|
||||||
<div key={entry.id} className="refill-history-item">
|
<div key={entry.id} className="refill-history-item">
|
||||||
<span className="refill-date">
|
<span className="refill-date">
|
||||||
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
{new Date(entry.refillDate).toLocaleDateString(
|
||||||
day: "2-digit",
|
getSystemLocale(i18n.language),
|
||||||
month: "short",
|
withFormattingTimezone({
|
||||||
year: "numeric",
|
day: "2-digit",
|
||||||
})}
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
)}
|
||||||
,{" "}
|
,{" "}
|
||||||
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), {
|
{new Date(entry.refillDate).toLocaleTimeString(
|
||||||
hour: "2-digit",
|
getSystemLocale(i18n.language),
|
||||||
minute: "2-digit",
|
withFormattingTimezone({
|
||||||
})}
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
)}
|
||||||
</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 && (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ 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; usedPrescription: boolean; refillDate: string }[];
|
||||||
@@ -121,7 +121,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");
|
||||||
@@ -415,12 +418,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 {
|
||||||
@@ -616,14 +619,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)
|
||||||
|
|||||||
@@ -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,26 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markDoseTaken(doseId: string) {
|
async function markDoseTaken(doseId: string) {
|
||||||
|
if (dismissedDoses.has(doseId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +283,104 @@ export function SharedSchedule() {
|
|||||||
// Revert on error
|
// Revert on error
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(doseId);
|
if (wasTaken) {
|
||||||
|
next.add(doseId);
|
||||||
|
} else {
|
||||||
|
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 +404,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 || options.isSkipped}
|
||||||
|
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("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 isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
|
const isDoseTakenAutomatically = (doseId: string) => automaticTakenDoses.has(doseId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -934,6 +1130,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 +1145,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 +1326,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 +1344,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 +1507,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 +1522,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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +183,7 @@ 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: [
|
||||||
@@ -251,6 +251,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: {
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+36
-36
@@ -6,7 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng",
|
"name": "medassist-ng",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.9",
|
"@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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||||
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
|
"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.9",
|
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||||
"@biomejs/cli-darwin-x64": "2.4.9",
|
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||||
"@biomejs/cli-linux-arm64": "2.4.9",
|
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||||
"@biomejs/cli-linux-arm64-musl": "2.4.9",
|
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||||
"@biomejs/cli-linux-x64": "2.4.9",
|
"@biomejs/cli-linux-x64": "2.4.14",
|
||||||
"@biomejs/cli-linux-x64-musl": "2.4.9",
|
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||||
"@biomejs/cli-win32-arm64": "2.4.9",
|
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||||
"@biomejs/cli-win32-x64": "2.4.9"
|
"@biomejs/cli-win32-x64": "2.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
"version": "2.4.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||||
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||||
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||||
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
|
"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.9",
|
"version": "2.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||||
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
|
"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.9",
|
"@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