Compare commits

...

11 Commits

Author SHA1 Message Date
Daniel Volz 3ec1460c4e feat: frontend improvements - shared schedule, bottle type, settings UI, planner notifications (#146)
- Rewrite SharedSchedule to match DashboardPage rendering with time-based consumption
- Add bottle package type support across all views (MedDetail, Refill, Planner, Dashboard)
- Redesign settings page with colored threshold chips, validation, and stock reminder display
- Add shareStockStatus toggle and send manual reminder button
- Pill/pills singular/plural consistency across all views
- Planner send notification via push (Shoutrrr) in addition to email
- Stock overflow warning and past-missed day styling
- Update README: bottles in Smart Inventory, push in Trip Planner, new ENV section
- 708 passing frontend tests including new coverage for all changes
2026-02-09 19:33:54 +01:00
Daniel Volz f56f2b7c88 feat: backend improvements - reminder tracking, share stock status, planner notifications (#145)
- Separate stock/intake reminder tracking in DB with dedicated columns
- Add shareStockStatus setting to control stock visibility on shared links
- Rewrite planner notification to support both email and Shoutrrr push
- Add push notification footer text for intake and stock reminders
- New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007)
- Update backend i18n with demandCalculator section and critically low text
- Add 514 passing backend tests including new coverage for all changes
2026-02-09 19:32:32 +01:00
github-actions[bot] 8ff652459d chore: update test count badges [skip ci] 2026-02-09 07:15:26 +00:00
Daniel Volz fb937e795b fix: planner usage calculation uses user-selected start date (#144)
The Demand Calculator used max(now, start) as the effective planner start,
which caused asymmetric counting when the current time fell between morning
and evening doses. For example, at 15:00 a medication with 07:00+20:00
intakes over 3 days showed 5 pills (2+3) instead of 6 (3+3) because the
morning dose on the start day was skipped while the evening was counted.

Changes:
- Use the user-selected start date directly instead of max(now, start)
- Optimize calculateUsageInRange to skip ahead to the relevant range
  instead of iterating from the original blister start date
- Add regression tests for asymmetric counting and blister-before-range
2026-02-09 08:10:13 +01:00
Daniel Volz 6d6f906a9a chore: update CI workflow and agent configuration (#143)
- docker-build.yml: build on tags + main, set latest only on tags
- release-manager.agent.md: add one-PR-per-feature/fix rule
2026-02-08 22:17:03 +01:00
Daniel Volz 3de1b2ef0c fix: UI polish for intake form, dashboard cards, and schedule (#142)
- Intake form: replace remind checkbox with bell icon + toggle switch
- Intake form: smart takenBy dropdown based on medication's people
- Dashboard: hide DETAILS row for pill bottles on mobile cards
- Dashboard: use status-chip with icons in schedule view (past/today/future)
- Dashboard: reduce spacing between icons and status chips on mobile
- MedDetailModal: show package type in PACKAGE DETAILS heading
- PlannerPage: show dash for bottle blisters column
- Shorten Pill Bottle label in EN/DE translations
- Update related tests
2026-02-08 22:13:52 +01:00
Daniel Volz b07b586eef chore: replace console.log with structured logging (#141)
- Add startup logger (utils/logger.ts) with LOG_LEVEL support
- Add ServiceLogger type for scheduler functions
- Replace all console.log calls with leveled log methods
- Downgrade verbose scheduler info logs to debug level
- Remove unnecessary console.log in auth plugin
2026-02-08 22:09:27 +01:00
Daniel Volz ffcd8983b4 revert: undo "fix: update backend and frontend images to use main tag" (#140)
This reverts commit cdf0088b0f.
2026-02-08 20:17:15 +00:00
daniel cdf0088b0f fix: update backend and frontend images to use 'main' tag 2026-02-08 19:47:23 +00:00
github-actions[bot] 152608731b chore: update test count badges [skip ci] 2026-02-08 19:37:42 +00:00
Daniel Volz 291a90d401 chore: release v1.9.0 (#139) 2026-02-08 20:34:29 +01:00
57 changed files with 5323 additions and 1013 deletions
+2 -1
View File
@@ -118,4 +118,5 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
+32
View File
@@ -18,6 +18,38 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
---
## PR Strategy: One PR per Feature/Fix
**Each feature or bug fix MUST be submitted as its own separate PR.** Do NOT bundle multiple unrelated changes into a single PR.
**Why:**
- Each change gets its own PR number for release notes (e.g., `(#140)`, `(#141)`)
- CI tests each change in isolation — failures are easy to trace
- Git blame and rollbacks are precise
- Code review stays focused
**Rules:**
- One logical change = one branch = one PR
- If a bug fix is discovered while working on a feature, create a **separate branch and PR** for the fix
- Related changes (e.g., a feature + its tests) belong in the **same** PR
- Squash-merge is still used — keeps `main` history clean with one commit per PR
- Branch naming reflects the change: `fix/bottle-stock-calc`, `feat/theme-dropdown`, etc.
**Example — bad (bundled):**
```
PR #138: "feat: theme dropdown, fix bottle bugs, fix planner, fix reminders"
```
**Example — good (separate):**
```
PR #138: "fix: bottle-type stock calculations across all subsystems"
PR #139: "fix: intake reminder past-intake seeding"
PR #140: "feat: theme dropdown with Light/Dark/System options"
PR #141: "fix: planner checkbox layout on single line"
```
---
## Task 1: Branch, PR, and Merge Workflow
When code changes (features or bug fixes) are complete and tested locally:
+4 -8
View File
@@ -3,11 +3,6 @@ name: Build and Push Docker Images
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose*.yml'
- '.github/workflows/docker-build.yml'
tags: ['v*']
workflow_dispatch:
inputs:
@@ -26,12 +21,13 @@ env:
jobs:
# =============================================================================
# Build and Push Docker Images
# Triggered on pushes to main (tagged as "main") and version tags (v*).
# Tests are NOT run here — branch protection on main requires all PR checks
# (backend-test + frontend-build from test.yml) to pass before merge.
# Tags are created from main, so code is already tested.
#
# Tag builds (v*) always set "latest" in addition to the semver tags.
# This ensures "latest" always points to the most recent release.
# main push → "main" tag only (for testing before release)
# Tag builds → semver tags (e.g., 1.9.0, 1.9) plus "latest"
# =============================================================================
build-and-push:
runs-on: ubuntu-latest
@@ -71,7 +67,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v5
+12 -3
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-494%2F494-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-653%2F653-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-504%2F504-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-662%2F662-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
@@ -120,7 +120,7 @@ Share your medication schedule with others via a public link.
</details>
### Smart Inventory
- Track exact stock: packs, blisters, and loose pills
- Track exact stock: packs, blisters, bottles, and loose pills
- Display remaining days of supply
- Automatic calculation based on intake schedule
@@ -141,6 +141,7 @@ Share your medication schedule with others via a public link.
### Trip Planner
- Calculate how many pills you need for a trip or date range
- Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
### Multi-Person Support
- Manage medications for multiple people
@@ -254,6 +255,14 @@ Configure push notifications in Settings → Push, or set defaults via environme
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
### Default User Settings
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
#### URL Examples
**ntfy** (free, self-hostable):
@@ -0,0 +1,3 @@
ALTER TABLE `user_settings` ADD `last_stock_reminder_sent` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_stock_reminder_channel` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_stock_reminder_med_names` text;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_stock_status` integer DEFAULT true NOT NULL;
+907
View File
@@ -0,0 +1,907 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d",
"prevId": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"package_type": {
"name": "package_type",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'blister'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"total_pills": {
"name": "total_pills",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dose_unit": {
"name": "dose_unit",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'mg'"
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"intakes_json": {
"name": "intakes_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_sent": {
"name": "last_stock_reminder_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_channel": {
"name": "last_stock_reminder_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_med_names": {
"name": "last_stock_reminder_med_names",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+915
View File
@@ -0,0 +1,915 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b6f1ee4b-cc31-4060-a4d4-bcd4fdc5bd87",
"prevId": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"package_type": {
"name": "package_type",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'blister'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"total_pills": {
"name": "total_pills",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dose_unit": {
"name": "dose_unit",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'mg'"
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"intakes_json": {
"name": "intakes_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"share_stock_status": {
"name": "share_stock_status",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_sent": {
"name": "last_stock_reminder_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_channel": {
"name": "last_stock_reminder_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_med_names": {
"name": "last_stock_reminder_med_names",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+14
View File
@@ -43,6 +43,20 @@
"when": 1769893708813,
"tag": "0005_add_intakes_json",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1770626907896,
"tag": "0006_add_stock_reminder_tracking",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1770659669121,
"tag": "0007_add_share_stock_status",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
"version": "1.8.8",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.8.8",
"version": "1.9.0",
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.8.8",
"version": "1.9.0",
"private": true,
"type": "module",
"scripts": {
+25 -25
View File
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql";
import { log } from "../utils/logger.js";
// Import utilities from db-utils (side-effect-free)
import {
ensureDataDirectory,
@@ -40,34 +40,34 @@ dotenv.config({ path: envPath });
// Use absolute path to ensure it works in Docker
const { dataDir, dbPath, url } = getDbPaths();
console.log(`[DB] Data directory: ${dataDir}`);
console.log(`[DB] Database path: ${dbPath}`);
console.log(`[DB] Database URL: ${url}`);
log.debug(`[DB] Data directory: ${dataDir}`);
log.debug(`[DB] Database path: ${dbPath}`);
log.debug(`[DB] Database URL: ${url}`);
// Ensure data directory exists and is writable
const dirResult = ensureDataDirectory(dataDir);
if (!dirResult.success) {
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
log.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
log.error(`[DB] Please ensure the volume mount has correct permissions.`);
log.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
process.exit(1);
} else {
console.log(`[DB] Data directory is writable`);
log.debug(`[DB] Data directory is writable`);
// Log directory stats
const stats = statSync(dataDir);
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
console.log(`[DB] Write test successful`);
log.debug(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
log.debug(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
log.debug(`[DB] Write test successful`);
}
let client: Client;
try {
client = createClient({ url });
console.log(`[DB] Database client created successfully`);
log.debug(`[DB] Database client created successfully`);
} catch (err: any) {
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
console.error(`[DB] Database path: ${dbPath}`);
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
log.error(`[DB] Database path: ${dbPath}`);
process.exit(1);
}
@@ -76,46 +76,46 @@ export const db = drizzle(client);
// Auto-run migrations (self-healing database)
async function runMigrations() {
// Run drizzle-kit generated migrations
console.log(`[DB] Running drizzle migrations...`);
log.info(`[DB] Running migrations...`);
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
console.error(`[DB] Migration error:`, migrateResult.error);
log.error(`[DB] Migration error: ${migrateResult.error}`);
} else if (migrateResult.warning) {
console.log(`[DB] Migration warning:`, migrateResult.warning);
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
} else {
console.log(`[DB] Drizzle migrations completed`);
log.debug(`[DB] Drizzle migrations completed`);
}
// Run ALTER TABLE migrations for backward compatibility
const alterResult = await runAlterMigrations(client);
if (alterResult.errors.length > 0) {
alterResult.errors.forEach((err) => console.error(`[DB] ALTER migration error:`, err));
alterResult.errors.forEach((err) => log.error(`[DB] ALTER migration error: ${err}`));
}
console.log(`[DB] Tables verified/created`);
log.debug(`[DB] Tables verified/created`);
// Repair dose IDs with trailing hyphens (from frontend takenBy bug)
const trailingResult = await repairTrailingHyphenDoseIds(client);
if (trailingResult.repaired > 0) {
console.log(`[DB] Repaired ${trailingResult.repaired} dose IDs with trailing hyphens`);
log.info(`[DB] Repaired ${trailingResult.repaired} dose IDs with trailing hyphens`);
}
if (trailingResult.errors.length > 0) {
trailingResult.errors.forEach((err) => console.error(`[DB] Trailing-hyphen repair error:`, err));
trailingResult.errors.forEach((err) => log.error(`[DB] Trailing-hyphen repair error: ${err}`));
}
// Repair orphaned dose tracking IDs from past schedule changes
const repairResult = await repairOrphanedDoseIds(client);
if (repairResult.repaired > 0) {
console.log(`[DB] Repaired ${repairResult.repaired} orphaned dose tracking IDs`);
log.info(`[DB] Repaired ${repairResult.repaired} orphaned dose tracking IDs`);
}
if (repairResult.errors.length > 0) {
repairResult.errors.forEach((err) => console.error(`[DB] Dose repair error:`, err));
repairResult.errors.forEach((err) => log.error(`[DB] Dose repair error: ${err}`));
}
// If auth is disabled, ensure a default user exists (ID=1)
const authEnabled = process.env.AUTH_ENABLED === "true";
const created = await ensureDefaultUser(client, authEnabled);
if (created) {
console.log(`[DB] Created default user for auth-disabled mode`);
log.info(`[DB] Created default user for auth-disabled mode`);
}
}
+10 -4
View File
@@ -88,10 +88,10 @@ export async function runDrizzleMigrations(
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
// If the error is "duplicate column", it means the schema is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns
// We consider this a success with a warning, not a failure
if (err.message?.includes("duplicate column")) {
// If the error is about existing schema objects, the DB is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns,
// or when tables were created before drizzle migrations were introduced
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
}
return { success: false, error: err.message };
@@ -129,6 +129,12 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
// Added for intake-level takenBy: unified intakes structure
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
// Added for separate stock reminder tracking
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
// Added for share stock visibility toggle
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
];
for (const sql of alterMigrations) {
+5 -5
View File
@@ -63,17 +63,17 @@ export function getStatementPreview(stmt: string, maxLength: number = 50): strin
const url = "file:./data/medassist-ng.db";
async function main() {
console.log("Starting database setup...");
console.log("Database URL:", url);
console.log("Migrations folder:", migrationsFolder);
console.log("[DB] Starting database setup...");
console.log("[DB] Database URL:", url);
console.log("[DB] Migrations folder:", migrationsFolder);
const client = createClient({ url });
const db = drizzle(client);
console.log("Running drizzle migrations...");
console.log("[DB] Running drizzle migrations...");
await migrate(db, { migrationsFolder });
console.log("Database setup complete!");
console.log("[DB] Database setup complete!");
process.exit(0);
}
+7 -1
View File
@@ -86,12 +86,18 @@ export const userSettings = sqliteTable("user_settings", {
language: text("language", { length: 10 }).notNull().default("en"),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Last notification tracking
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Last notification tracking (intake reminders)
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
lastNotificationChannel: text("last_notification_channel"),
lastReminderMedName: text("last_reminder_med_name"),
lastReminderTakenBy: text("last_reminder_taken_by"),
// Last stock reminder tracking (separate from intake)
lastStockReminderSent: text("last_stock_reminder_sent"),
lastStockReminderChannel: text("last_stock_reminder_channel"),
lastStockReminderMedNames: text("last_stock_reminder_med_names"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
+154 -25
View File
@@ -64,20 +64,29 @@ function getRegionFromTimezone(): string | undefined {
}
type TranslationKeys = {
// Stock reminder email
// Stock reminder (shared across email + push)
stockReminder: {
subject: string;
title: string;
description: string;
descriptionEmpty: string;
descriptionMixed: string;
alertSingle: string;
alertMultiple: string;
alertEmptySingle: string;
alertEmptyMultiple: string;
alertLowSingle: string;
alertLowMultiple: string;
alertLowStockSingle: string;
alertLowStockMultiple: string;
descriptionLow: string;
tableHeaders: {
medication: string;
pills: string;
days: string;
runsOut: string;
};
footer: string;
now: string;
repeatDailyNote: string;
};
// Intake reminder email
@@ -94,7 +103,6 @@ type TranslationKeys = {
};
pills: string;
takenBy: string;
footer: string;
};
// Push notifications
push: {
@@ -107,35 +115,68 @@ type TranslationKeys = {
repeatDailyNote: string;
empty: string;
low: string;
critical: string;
lowStock: string;
reorderNow: string;
emptySection: string;
lowSection: string;
criticalSection: string;
lowStockSection: string;
};
// Demand calculator email
demandCalculator: {
subject: string;
title: string;
description: string;
summaryOutOfStock: string;
summaryAllOk: string;
tableHeaders: {
medication: string;
usage: string;
needed: string;
available: string;
status: string;
};
statusEnough: string;
statusEmpty: string;
};
// Common
common: {
pill: string;
pills: string;
blister: string;
blisters: string;
day: string;
days: string;
soon: string;
footer: string;
};
};
const translations: Record<Language, TranslationKeys> = {
en: {
stockReminder: {
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Low",
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Critically Low",
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
description: "The following medications are running low and need to be reordered:",
alertSingle: "⚠️ 1 medication running low!",
alertMultiple: "⚠️ {count} medications running low!",
description: "The following medications are running critically low and need to be reordered:",
descriptionEmpty: "The following medications are empty and need to be reordered immediately:",
descriptionMixed: "The following medications need to be reordered:",
alertSingle: "⚠️ 1 medication running critically low!",
alertMultiple: "⚠️ {count} medications running critically low!",
alertEmptySingle: "🚨 1 medication empty - reorder immediately!",
alertEmptyMultiple: "🚨 {count} medications empty - reorder immediately!",
alertLowSingle: "⚠️ 1 medication running critically low",
alertLowMultiple: "⚠️ {count} medications running critically low",
alertLowStockSingle: "⚠️ 1 medication running low",
alertLowStockMultiple: "⚠️ {count} medications running low",
descriptionLow: "The following medications are running low and should be reordered soon:",
tableHeaders: {
medication: "Medication",
pills: "Pills",
days: "Days",
runsOut: "Runs Out",
},
footer: "🤖 Automatic reminder from MedAssist-ng",
now: "NOW",
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
},
intakeReminder: {
@@ -151,44 +192,75 @@ const translations: Record<Language, TranslationKeys> = {
},
pills: "pills",
takenBy: "for {name}",
footer: "🤖 Automatic reminder from MedAssist-ng",
},
push: {
stockTitle: "MedAssist-ng: 1 Medication Running Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low",
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
repeatDailyNote: "(Daily reminder enabled)",
empty: "Empty",
low: "Low",
low: "Critical",
critical: "Critical",
lowStock: "Low",
reorderNow: "Reorder Now!",
emptySection: "EMPTY (reorder immediately)",
lowSection: "RUNNING LOW (reorder soon)",
emptySection: "Empty (reorder immediately)",
lowSection: "Running critically low",
criticalSection: "Running critically low",
lowStockSection: "Running low",
},
demandCalculator: {
subject: "MedAssist-ng - Supply Overview ({from} - {until})",
title: "MedAssist-ng - Demand Calculator",
description: "Supply overview from {from} to {until}",
summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.",
summaryAllOk: "✓ All medications have sufficient supply for this period.",
tableHeaders: {
medication: "Medication",
usage: "Usage",
needed: "Blisters needed",
available: "Available",
status: "Status",
},
statusEnough: "✓ Enough",
statusEmpty: "✗ Empty",
},
common: {
pill: "pill",
pills: "pills",
blister: "blister",
blisters: "blisters",
day: "day",
days: "days",
soon: "soon",
footer: "🤖 Sent from MedAssist-ng",
},
},
de: {
stockReminder: {
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} wird knapp",
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} kritisch niedrig",
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament wird knapp!",
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:",
descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:",
descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament kritisch niedrig!",
alertMultiple: "⚠️ {count} Medikamente kritisch niedrig!",
alertEmptySingle: "🚨 1 Medikament leer - sofort nachbestellen!",
alertEmptyMultiple: "🚨 {count} Medikamente leer - sofort nachbestellen!",
alertLowSingle: "⚠️ 1 Medikament kritisch niedrig",
alertLowMultiple: "⚠️ {count} Medikamente kritisch niedrig",
alertLowStockSingle: "⚠️ 1 Medikament niedrig",
alertLowStockMultiple: "⚠️ {count} Medikamente niedrig",
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
days: "Tage",
runsOut: "Aufgebraucht",
},
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
now: "JETZT",
repeatDailyNote:
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
},
@@ -205,28 +277,50 @@ const translations: Record<Language, TranslationKeys> = {
},
pills: "Tabletten",
takenBy: "für {name}",
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
},
push: {
stockTitle: "MedAssist-ng: 1 Medikament wird knapp",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp",
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
empty: "Leer",
low: "Knapp",
low: "Kritisch",
critical: "Kritisch",
lowStock: "Niedrig",
reorderNow: "Jetzt nachbestellen!",
emptySection: "LEER (sofort nachbestellen)",
lowSection: "WIRD KNAPP (bald nachbestellen)",
emptySection: "Leer (sofort nachbestellen)",
lowSection: "Kritisch niedrig",
criticalSection: "Kritisch niedrig",
lowStockSection: "Niedrig",
},
demandCalculator: {
subject: "MedAssist-ng - Bestandsübersicht ({from} - {until})",
title: "MedAssist-ng - Bedarfsrechner",
description: "Bestandsübersicht von {from} bis {until}",
summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.",
summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.",
tableHeaders: {
medication: "Medikament",
usage: "Verbrauch",
needed: "Blister benötigt",
available: "Verfügbar",
status: "Status",
},
statusEnough: "✓ Ausreichend",
statusEmpty: "✗ Leer",
},
common: {
pill: "Tablette",
pills: "Tabletten",
blister: "Blister",
blisters: "Blister",
day: "Tag",
days: "Tage",
soon: "bald",
footer: "🤖 Gesendet von MedAssist-ng",
},
},
};
@@ -264,3 +358,38 @@ export function getDateLocale(language: Language): string {
return "en-US";
}
}
/**
* Get the app URL from the first CORS_ORIGINS entry.
* Falls back to empty string if not set.
*/
export function getAppUrl(): string {
const origins = process.env.CORS_ORIGINS || "";
return origins.split(",")[0]?.trim() || "";
}
/**
* Get the unified footer as HTML with MedAssist-ng as a link to the instance.
* @param variant - 'planner' uses the Medication Planner footer text
*/
export function getFooterHtml(language: Language): string {
const tr = getTranslations(language);
const appUrl = getAppUrl();
const appName = appUrl
? `<a href="${appUrl}" style="color: #6b7280; text-decoration: underline;">MedAssist-ng</a>`
: "MedAssist-ng";
return tr.common.footer.replace("MedAssist-ng", appName);
}
/**
* Get the unified footer as plain text.
* @param variant - 'planner' uses the Medication Planner footer text
*/
export function getFooterPlain(language: Language): string {
const tr = getTranslations(language);
const appUrl = getAppUrl();
if (appUrl) {
return `${tr.common.footer} (${appUrl})`;
}
return tr.common.footer;
}
+5 -1
View File
@@ -126,9 +126,11 @@ export async function createApp(options?: {
// Server initialization (runs on import)
// =============================================================================
import { log } from "./utils/logger.js";
// Wait for database migrations before anything else
await migrationsReady;
console.log("[DB] Migrations complete, starting server...");
log.info("[DB] Migrations complete, starting server...");
// Ensure images directory exists
const imagesDir = ensureImagesDirectory();
@@ -197,12 +199,14 @@ const start = async () => {
// Start the automatic reminder scheduler
startReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
error: (msg) => app.log.error(msg),
});
// Start the intake reminder scheduler (checks every minute)
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
-1
View File
@@ -37,7 +37,6 @@ export async function getAnonymousUserId(): Promise<number> {
`);
anonymousUserVerified = true;
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
return ANONYMOUS_USER_ID;
}
+25 -5
View File
@@ -731,9 +731,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
// Calculate usage for the planning period
// Always use the user-selected start date for the usage calculation.
// Using max(now, start) would cause asymmetric counting when now falls
// between morning and evening doses on the start day (e.g., morning dose
// skipped but evening counted), leading to confusing off-by-one results.
// The stock already reflects consumed doses, so no double-counting occurs.
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
// When false, calculate from max(now, start) to end (default behavior)
const effectivePlannerStart = includeUntilStart ? now : new Date(Math.max(now.getTime(), start.getTime()));
const effectivePlannerStart = includeUntilStart ? now : start;
const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end);
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
@@ -840,12 +844,28 @@ function calculateUsageInRange(
end: Date
) {
let total = 0;
const msPerDay = 86400000;
blisters.forEach((blister) => {
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
// iterate occurrences from blisterStart up to end
for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) {
if (dt >= start && dt < end) total += blister.usage;
const every = Math.max(1, blister.every);
// Skip ahead to the first occurrence at or after start to avoid
// iterating through months/years of past doses
const dt = new Date(blisterStart);
if (dt < start) {
const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay));
dt.setDate(dt.getDate() + daysToSkip * every);
// Fine-tune: advance until we reach or pass start
while (dt < start) {
dt.setDate(dt.getDate() + every);
}
}
// Count occurrences in [start, end)
for (; dt < end; dt.setDate(dt.getDate() + every)) {
total += blister.usage;
}
});
return Number(total.toFixed(2));
+4 -4
View File
@@ -201,7 +201,7 @@ export async function oidcRoutes(app: FastifyInstance) {
});
// Set cookies (use app's centralized cookie options)
console.log(
request.log.debug(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
@@ -241,12 +241,12 @@ async function findOrCreateOIDCUser(
if (existingByUsername.authProvider === "local" && !existingByUsername.oidcSubject) {
// Local user exists without SSO - link this OIDC account to existing user
await db.update(users).set({ oidcSubject: oidcSubject }).where(eq(users.id, existingByUsername.id));
console.log(`[OIDC] Linked OIDC to existing local user: ${username}`);
// Linked OIDC to existing local user
return { id: existingByUsername.id, username: existingByUsername.username };
} else if (existingByUsername.oidcSubject && existingByUsername.oidcSubject !== oidcSubject) {
// User already has a DIFFERENT OIDC subject - create new user with suffix
username = `${username}_sso`;
console.log(`[OIDC] Username collision (different OIDC subject), using: ${username}`);
// Username collision (different OIDC subject), use suffixed name
}
}
@@ -268,7 +268,7 @@ async function findOrCreateOIDCUser(
})
.returning({ id: users.id, username: users.username });
console.log(`[OIDC] Created new user: ${newUser.username} (ID: ${newUser.id})`);
// New OIDC user created
return newUser;
}
+278 -191
View File
@@ -1,6 +1,13 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import nodemailer from "nodemailer";
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
import {
getDateLocale,
getFooterHtml,
getFooterPlain,
getTranslations,
type Language,
t,
} from "../i18n/translations.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
@@ -29,6 +36,7 @@ type PlannerRow = {
fullBlisters: number;
loosePills: number;
enough: boolean;
packageType?: string;
};
type SendEmailBody = {
@@ -44,6 +52,7 @@ type LowStockItem = {
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
};
type ReminderEmailBody = {
@@ -68,32 +77,28 @@ export async function plannerRoutes(app: FastifyInstance) {
return authUser.id;
}
// Demand calculator notification (supports email and push)
app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
if (!email || !rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing email or planner data" });
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
}
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (!smtpHost || !smtpUser) {
return reply.status(400).send({ error: "SMTP not configured" });
}
// Load user settings for notification channels
const userId = await getUserId(request);
const userSettings = await loadUserSettings(userId);
const notificationSettings = {
emailEnabled: userSettings.emailEnabled,
shoutrrrEnabled: userSettings.shoutrrrEnabled,
shoutrrrUrl: userSettings.shoutrrrUrl || "",
};
// Get locale from user settings or use the language passed in the body
let language: Language = bodyLanguage || "en";
const authUser = request.user as unknown as AuthUser | null;
if (authUser?.id) {
const userSettings = await loadUserSettings(authUser.id);
language = userSettings.language;
}
const language: Language = (userSettings.language as Language) || bodyLanguage || "en";
const locale = getDateLocale(language);
const tr = getTranslations(language);
const dc = tr.demandCalculator;
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
@@ -111,47 +116,93 @@ export async function plannerRoutes(app: FastifyInstance) {
})
);
// Build HTML table with horizontal scroll for mobile
// Escape/coerce all user-provided values to prevent XSS
const tableRows = rows
.map((row) => {
const safeName = escapeHtml(row.medicationName);
const safeTotalPills = Number(row.totalPills) || 0;
const safePlannerUsage = Number(row.plannerUsage) || 0;
const safeBlistersNeeded = Number(row.blistersNeeded) || 0;
const safeBlisterSize = Number(row.blisterSize) || 0;
const safeFullBlisters = Number(row.fullBlisters) || 0;
const safeLoosePills = Number(row.loosePills) || 0;
return `
const outOfStockCount = rows.filter((r) => !r.enough).length;
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
// Build plain text (shared between email and push)
const plainText = `${dc.title}
${t(dc.description, { from: fromDate, until: untilDate })}
${summaryText}
${rows
.map((r) => {
const isBottle = r.packageType === "bottle";
const usage = `${r.plannerUsage} ${tr.common.pills}`;
const needed = isBottle ? "" : `${r.blistersNeeded} × ${r.blisterSize}`;
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
const available = isBottle
? `${loosePills} ${tr.common.pills}`
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
return `${r.medicationName}: ${usage}, ${needed}, ${available} - ${status}`;
})
.join("\n")}
---
${getFooterPlain(language)}`;
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
// Send email if enabled
if (notificationSettings.emailEnabled && email) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
// Build HTML table with horizontal scroll for mobile
// Escape/coerce all user-provided values to prevent XSS
const tableRows = rows
.map((row) => {
const safeName = escapeHtml(row.medicationName);
const safePlannerUsage = Number(row.plannerUsage) || 0;
const safeBlistersNeeded = Number(row.blistersNeeded) || 0;
const safeBlisterSize = Number(row.blisterSize) || 0;
const safeFullBlisters = Number(row.fullBlisters) || 0;
const safeLoosePills = Math.round((Number(row.loosePills) || 0) * 10) / 10;
const isBottle = row.packageType === "bottle";
// "Blisters needed" column: dash for bottles
const neededCell = isBottle ? "" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
// "Available" column: match frontend format
let availableCell: string;
if (isBottle) {
availableCell = `${safeLoosePills} ${tr.common.pills}`;
} else {
availableCell = `${safeFullBlisters} ${tr.common.blisters}`;
if (safeLoosePills > 0) {
availableCell += ` + ${safeLoosePills} ${tr.common.pills}`;
}
}
return `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safeTotalPills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeBlistersNeeded} × ${safeBlisterSize}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeFullBlisters}${safeLoosePills > 0 ? ` (+${safeLoosePills})` : ""}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
row.enough ? "background: #d1fae5; color: #065f46;" : "background: #fee2e2; color: #991b1b;"
}">
${row.enough ? "✓ OK" : "✗ Out of Stock"}
${row.enough ? dc.statusEnough : dc.statusEmpty}
</span>
</td>
</tr>
`;
})
.join("");
})
.join("");
const outOfStockCount = rows.filter((r) => !r.enough).length;
const summaryText =
outOfStockCount > 0
? `⚠️ ${outOfStockCount} medication${outOfStockCount > 1 ? "s" : ""} will be out of stock during this period.`
: "✓ All medications have sufficient supply for this period.";
const html = `
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">MedAssist-ng - Demand Calculator</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Supply overview from <strong>${fromDate}</strong> to <strong>${untilDate}</strong></p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${dc.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(dc.description, { from: `<strong>${fromDate}</strong>`, until: `<strong>${untilDate}</strong>` })}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; ${
outOfStockCount > 0
@@ -167,12 +218,11 @@ export async function plannerRoutes(app: FastifyInstance) {
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 550px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Stock</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Usage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Needed</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Available</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">Status</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.usage}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.needed}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.available}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.status}</th>
</tr>
</thead>
<tbody>
@@ -182,44 +232,76 @@ export async function plannerRoutes(app: FastifyInstance) {
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist-ng Medication Planner</p>
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
</div>
</div>
`;
const plainText = `MedAssist-ng - Demand Calculator
Supply overview from ${fromDate} to ${untilDate}
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
${summaryText}
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: t(dc.subject, { from: fromDate, until: untilDate }),
text: plainText,
html,
});
${rows.map((r) => `${r.medicationName}: ${r.totalPills} pills in stock, ${r.plannerUsage} pills needed, ${r.fullBlisters} blisters available${r.loosePills > 0 ? ` (+${r.loosePills} loose)` : ""} (${r.blistersNeeded} needed) - ${r.enough ? "Enough" : "OUT OF STOCK"}`).join("\n")}
results.email = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Email: ${errorMessage}`);
}
}
}
---
Sent from MedAssist-ng Medication Planner`;
// Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
const pushMessage = `${summaryText}\n\n${rows
.map((r) => {
const usage = `${r.plannerUsage} ${tr.common.pills}`;
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
return `${r.enough ? "✓" : "✗"} ${r.medicationName}: ${usage} - ${status}`;
})
.join("\n")}\n\n---\n${getFooterPlain(language)}`;
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
try {
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, pushTitle, pushMessage);
if (pushResult.success) {
results.push = true;
} else {
results.errors.push(`Push: ${pushResult.error}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
results.errors.push(`Push: ${errorMessage}`);
}
}
// Build response message
const sentChannels: string[] = [];
if (results.email) sentChannels.push("email");
if (results.push) sentChannels.push("push");
if (sentChannels.length > 0) {
return reply.send({
success: true,
message: `Notification sent via ${sentChannels.join(" and ")}`,
});
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `MedAssist-ng - Supply Overview (${fromDate} - ${untilDate})`,
text: plainText,
html,
});
return reply.send({ success: true, message: "Email sent successfully" });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send email: ${errorMessage}` });
} else if (results.errors.length > 0) {
return reply.status(500).send({ error: results.errors.join("; ") });
} else {
return reply.status(400).send({ error: "No notification channels configured" });
}
});
@@ -240,11 +322,66 @@ Sent from MedAssist-ng Medication Planner`;
shoutrrrUrl: userSettings.shoutrrrUrl || "",
};
// Get translations based on user language
const language = (userSettings.language as Language) || "en";
const tr = getTranslations(language);
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
// Separate empty from low stock medications
// Separate into 3 categories: empty, critical, and low stock
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
const lowMeds = lowStock.filter((r) => r.medsLeft > 0);
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
// Build shared notification content (method-agnostic)
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
}
if (criticalMeds.length > 0) {
titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical}`);
}
if (lowStockMeds.length > 0) {
titleParts.push(`⚠️ ${lowStockMeds.length} ${tr.push.lowStock}`);
}
const notificationTitle = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
// Build description text
let descriptionText: string;
if (emptyMeds.length > 0 && (criticalMeds.length > 0 || lowStockMeds.length > 0)) {
descriptionText = tr.stockReminder.descriptionMixed;
} else if (emptyMeds.length > 0) {
descriptionText = tr.stockReminder.descriptionEmpty;
} else if (criticalMeds.length > 0) {
descriptionText = tr.stockReminder.description;
} else {
descriptionText = tr.stockReminder.descriptionLow;
}
// Build section-based message (shared between email plain text and push)
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyMeds.forEach((r) => messageParts.push(`${r.name}`));
}
if (criticalMeds.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
if (lowStockMeds.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowStockMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
// Send email if enabled
if (notificationSettings.emailEnabled && email) {
@@ -256,52 +393,59 @@ Sent from MedAssist-ng Medication Planner`;
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
// Build subject line based on what we have
let subjectText: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
subjectText = `🚨 ${emptyMeds.length} Empty, ⚠️ ${lowMeds.length} Running Low`;
} else if (emptyMeds.length > 0) {
subjectText = `🚨 ${emptyMeds.length} Medication${emptyMeds.length > 1 ? "s" : ""} Empty`;
} else {
subjectText = `⚠️ ${lowMeds.length} Medication${lowMeds.length > 1 ? "s" : ""} Running Low`;
}
// Build subject line from shared title parts
const subjectText = titleParts.join(", ");
// Build alert box based on what we have
let alertHtml: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
alertHtml = `
// Build alert boxes for each category
const alertParts: string[] = [];
if (emptyMeds.length > 0) {
const emptyAlert =
emptyMeds.length === 1
? tr.stockReminder.alertEmptySingle
: t(tr.stockReminder.alertEmptyMultiple, { count: emptyMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
${emptyAlert}
</p>
</div>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
} else if (emptyMeds.length > 0) {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #dc2626;">
<p style="margin: 0; color: #dc2626; font-weight: 600; font-size: 13px;">
🚨 ${emptyMeds.length} medication${emptyMeds.length > 1 ? "s" : ""} EMPTY - reorder immediately!
</p>
</div>`;
} else {
alertHtml = `
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
⚠️ ${lowMeds.length} medication${lowMeds.length > 1 ? "s" : ""} running low - reorder soon
</p>
</div>`;
</div>`);
}
if (criticalMeds.length > 0) {
const criticalAlert =
criticalMeds.length === 1
? tr.stockReminder.alertLowSingle
: t(tr.stockReminder.alertLowMultiple, { count: criticalMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fff7ed; border: 1px solid #ea580c;">
<p style="margin: 0; color: #c2410c; font-weight: 600; font-size: 13px;">
${criticalAlert}
</p>
</div>`);
}
if (lowStockMeds.length > 0) {
const lowAlert =
lowStockMeds.length === 1
? tr.stockReminder.alertLowStockSingle
: t(tr.stockReminder.alertLowStockMultiple, { count: lowStockMeds.length });
alertParts.push(`
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; background: #fffbeb; border: 1px solid #f59e0b;">
<p style="margin: 0; color: #b45309; font-weight: 500; font-size: 13px;">
${lowAlert}
</p>
</div>`);
}
const alertHtml = alertParts.join("");
// Build table rows with status indicator
const buildTableRow = (row: LowStockItem) => {
const isEmpty = row.medsLeft <= 0;
const statusIcon = isEmpty ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : "white";
// Escape user-provided strings and coerce numbers to prevent XSS
const isCritical = row.isCritical !== false;
const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️";
const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white";
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
const safeDaysLeft = Number(row.daysLeft) || 0;
@@ -311,26 +455,16 @@ Sent from MedAssist-ng Medication Planner`;
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? "<strong>NOW</strong>" : safeDepletionDate}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
</tr>`;
};
const tableRows = lowStock.map(buildTableRow).join("");
// Build description text
let descriptionText: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
descriptionText = "The following medications need to be reordered:";
} else if (emptyMeds.length > 0) {
descriptionText = "The following medications are EMPTY and need to be reordered immediately:";
} else {
descriptionText = "The following medications are running low and need to be reordered:";
}
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - Reorder Reminder</h2>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyMeds.length > 0 ? "🚨" : "⚠️"} MedAssist-ng - ${tr.push.reorderNow}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${descriptionText}</p>
${alertHtml}
@@ -339,10 +473,10 @@ Sent from MedAssist-ng Medication Planner`;
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.pills}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.days}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.runsOut}</th>
</tr>
</thead>
<tbody>
@@ -352,33 +486,12 @@ Sent from MedAssist-ng Medication Planner`;
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">Sent from MedAssist-ng Medication Planner</p>
<p style="color: #9ca3af; font-size: 11px; margin: 0;">${getFooterHtml(language)}</p>
</div>
</div>
`;
// Build plain text with sections
let plainTextContent: string;
if (emptyMeds.length > 0 && lowMeds.length > 0) {
plainTextContent = `🚨 EMPTY (reorder immediately):
${emptyMeds.map((r) => `${r.name}`).join("\n")}
⚠️ RUNNING LOW (reorder soon):
${lowMeds.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining`).join("\n")}`;
} else if (emptyMeds.length > 0) {
plainTextContent = `🚨 EMPTY (reorder immediately):
${emptyMeds.map((r) => `${r.name}`).join("\n")}`;
} else {
plainTextContent = `⚠️ Running low:
${lowMeds.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}`;
}
const plainText = `MedAssist-ng - Reorder Reminder
${plainTextContent}
---
Sent from MedAssist-ng Medication Planner`;
const plainText = `MedAssist-ng - ${tr.push.reorderNow}\n\n${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
try {
const transporter = nodemailer.createTransport({
@@ -409,38 +522,10 @@ Sent from MedAssist-ng Medication Planner`;
// Send push notification if enabled
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
// Get translations based on user language (default to 'en')
const tr = getTranslations((userSettings.language as Language) || "en");
// Build clear title
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty}`);
}
if (lowMeds.length > 0) {
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low}`);
}
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
// Build clear message with sections
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyMeds.forEach((r) => messageParts.push(`${r.name}`));
}
if (lowMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection}:`);
lowMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
const message = messageParts.join("\n");
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
try {
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, title, message);
const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message);
if (pushResult.success) {
results.push = true;
} else {
@@ -458,7 +543,9 @@ Sent from MedAssist-ng Medication Planner`;
updateReminderSentTime("stock", channel);
// Also update user settings in database so frontend can display the info
await updateUserReminderSentTime(userId, "stock", channel);
const firstMed = lowStock[0];
const medNames = lowStock.length > 1 ? `${firstMed.name} (+${lowStock.length - 1})` : firstMed?.name;
await updateUserReminderSentTime(userId, "stock", channel, medNames);
}
// Build response message
+23
View File
@@ -30,11 +30,15 @@ export type UserSettings = {
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
lastAutoEmailSent: string | null;
lastNotificationType: string | null;
lastNotificationChannel: string | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: string | null;
lastStockReminderMedNames: string | null;
};
type SettingsBody = {
@@ -57,6 +61,7 @@ type SettingsBody = {
maxNaggingReminders: number;
language: string;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
};
type TestEmailBody = {
@@ -104,11 +109,15 @@ function getDefaultSettings() {
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareStockStatus: envBool("DEFAULT_SHARE_STOCK_STATUS", true),
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
};
}
@@ -154,11 +163,15 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
};
}
@@ -186,11 +199,15 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
}));
}
@@ -241,6 +258,7 @@ export async function settingsRoutes(app: FastifyInstance) {
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
shareStockStatus: settings.shareStockStatus ?? true,
// SMTP settings (from .env - shared/server-configured)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587", 10),
@@ -254,6 +272,10 @@ export async function settingsRoutes(app: FastifyInstance) {
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
// Stock reminder tracking (separate from intake)
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
// Server settings (from .env, read-only)
expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10),
});
@@ -296,6 +318,7 @@ export async function settingsRoutes(app: FastifyInstance) {
highStockDays: body.highStockDays ?? 180,
language: body.language ?? "en",
stockCalculationMode: body.stockCalculationMode ?? "automatic",
shareStockStatus: body.shareStockStatus ?? true,
updatedAt: new Date(),
};
+8
View File
@@ -135,6 +135,8 @@ export async function shareRoutes(app: FastifyInstance) {
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
@@ -145,7 +147,13 @@ export async function shareRoutes(app: FastifyInstance) {
medications: medicationsWithBlisters,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
};
});
@@ -5,8 +5,16 @@ import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js";
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
import {
getDateLocale,
getFooterHtml,
getFooterPlain,
getTranslations,
type Language,
t,
} from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
// Import shared utilities
import {
cleanOldIntakeReminders,
@@ -149,7 +157,7 @@ async function sendIntakeReminderEmail(
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
${tr.intakeReminder.footer}
${getFooterHtml(language)}
</p>
</div>
</div>
@@ -178,7 +186,7 @@ ${intakes
.join("\n")}
---
${tr.intakeReminder.footer}`;
${getFooterPlain(language)}`;
const subject = isRepeat
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
@@ -210,21 +218,18 @@ ${tr.intakeReminder.footer}`;
}
}
async function checkAndSendIntakeReminders(logger: {
info: (msg: string) => void;
error: (msg: string) => void;
}): Promise<void> {
logger.info(`[IntakeReminder] Checking for intake reminders...`);
async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void> {
logger.debug(`[IntakeReminder] Checking for intake reminders...`);
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
if (allUserSettings.length === 0) {
logger.info(`[IntakeReminder] No users with settings found`);
logger.debug(`[IntakeReminder] No users with settings found`);
return; // No users with settings
}
logger.info(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
@@ -233,12 +238,12 @@ async function checkAndSendIntakeReminders(logger: {
async function checkAndSendIntakeRemindersForUser(
settings: UserSettings & { userId: number },
logger: { info: (msg: string) => void; error: (msg: string) => void }
logger: ServiceLogger
): Promise<void> {
const language = settings.language;
const tr = getTranslations(language);
logger.info(
logger.debug(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
@@ -247,13 +252,13 @@ async function checkAndSendIntakeRemindersForUser(
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
if (!emailEnabled && !shoutrrrEnabled) {
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
return; // No intake reminder notifications enabled for this user
}
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
@@ -266,11 +271,13 @@ async function checkAndSendIntakeRemindersForUser(
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
return; // No medications have reminders enabled for this user
}
logger.info(`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
);
const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
@@ -285,7 +292,7 @@ async function checkAndSendIntakeRemindersForUser(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
);
@@ -300,7 +307,7 @@ async function checkAndSendIntakeRemindersForUser(
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
);
@@ -308,7 +315,7 @@ async function checkAndSendIntakeRemindersForUser(
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.info(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
@@ -316,7 +323,7 @@ async function checkAndSendIntakeRemindersForUser(
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
);
@@ -333,7 +340,7 @@ async function checkAndSendIntakeRemindersForUser(
med.id,
med.doseUnit ?? "mg"
);
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
);
@@ -358,13 +365,13 @@ async function checkAndSendIntakeRemindersForUser(
med.id,
med.doseUnit ?? "mg"
);
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
);
const missedIntakes = allTodaysIntakes.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
);
@@ -383,10 +390,10 @@ async function checkAndSendIntakeRemindersForUser(
});
}
logger.info(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
if (allUpcoming.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
return; // No upcoming intakes for today
}
@@ -419,13 +426,13 @@ async function checkAndSendIntakeRemindersForUser(
sendCount: 0,
advanceSent: false,
};
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — first detection)`
);
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
);
}
@@ -440,13 +447,13 @@ async function checkAndSendIntakeRemindersForUser(
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
);
} else if (timeSinceLastReminder >= intervalMs) {
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
);
}
@@ -486,7 +493,7 @@ async function checkAndSendIntakeRemindersForUser(
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
@@ -496,7 +503,7 @@ async function checkAndSendIntakeRemindersForUser(
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.info(
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
@@ -505,7 +512,7 @@ async function checkAndSendIntakeRemindersForUser(
});
if (remindersToSend.length === 0) {
logger.info(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
return;
}
}
@@ -601,7 +608,9 @@ async function checkAndSendIntakeRemindersForUser(
}
return `${i.medName}${takenByStr}: ${dosage} @ ${i.intakeTimeStr}`;
})
.join("\n") + repeatNote;
.join("\n") +
repeatNote +
`\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
@@ -679,10 +688,7 @@ async function checkAndSendIntakeRemindersForUser(
let intakeCheckInterval: NodeJS.Timeout | null = null;
export function startIntakeReminderScheduler(logger: {
info: (msg: string) => void;
error: (msg: string) => void;
}): void {
export function startIntakeReminderScheduler(logger: ServiceLogger): void {
logger.info(`[IntakeReminder] Starting intake reminder scheduler (checks every minute)...`);
// Run immediately on start
+44 -32
View File
@@ -5,9 +5,9 @@ import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { medications, userSettings } from "../db/schema.js";
import { getTranslations, type Language, t } from "../i18n/translations.js";
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
// Import shared utilities
import {
type Blister,
@@ -63,6 +63,7 @@ export function updateReminderSentTime(
}
// Update user settings in database when reminder is sent
// Stock and intake reminders are tracked separately so neither overwrites the other
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" = "stock",
@@ -71,16 +72,30 @@ export async function updateUserReminderSentTime(
takenBy?: string
): Promise<void> {
const now = new Date().toISOString();
await db
.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
lastReminderMedName: medName ?? null,
lastReminderTakenBy: takenBy ?? null,
})
.where(eq(userSettings.userId, userId));
if (type === "stock") {
// Write to dedicated stock reminder columns only — do NOT touch the shared
// lastNotificationType column, as that would block intake reminder display
await db
.update(userSettings)
.set({
lastStockReminderSent: now,
lastStockReminderChannel: channel,
lastStockReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
} else {
// Write to intake reminder columns
await db
.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
lastReminderMedName: medName ?? null,
lastReminderTakenBy: takenBy ?? null,
})
.where(eq(userSettings.userId, userId));
}
}
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
@@ -191,7 +206,7 @@ async function sendReminderEmail(
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
${tr.stockReminder.footer}
${getFooterHtml(language)}
</p>
${isRepeatDaily ? `<p style="color: #9ca3af; font-size: 11px; margin: 8px 0 0 0; font-style: italic;">${tr.stockReminder.repeatDailyNote}</p>` : ""}
</div>
@@ -205,7 +220,7 @@ ${tr.stockReminder.description}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
---
${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s";
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
@@ -236,15 +251,12 @@ ${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyN
}
}
async function checkAndSendReminder(logger: {
info: (msg: string) => void;
error: (msg: string) => void;
}): Promise<void> {
async function checkAndSendReminder(logger: ServiceLogger): Promise<void> {
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
if (allUserSettings.length === 0) {
logger.info("[Reminder] No users with settings found");
logger.debug("[Reminder] No users with settings found");
return;
}
@@ -255,7 +267,7 @@ async function checkAndSendReminder(logger: {
async function checkAndSendReminderForUser(
settings: UserSettings & { userId: number },
logger: { info: (msg: string) => void; error: (msg: string) => void }
logger: ServiceLogger
): Promise<void> {
const language = settings.language;
const tr = getTranslations(language);
@@ -308,30 +320,30 @@ async function checkAndSendReminderForUser(
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
// Separate empty from low stock medications
// Separate empty from critical stock medications (all auto-reminder meds are critical by definition)
const emptyMeds = allLowStock.filter((m) => m.medsLeft <= 0);
const lowMeds = allLowStock.filter((m) => m.medsLeft > 0);
const criticalMeds = allLowStock.filter((m) => m.medsLeft > 0);
// Build clear title
const titleParts: string[] = [];
if (emptyMeds.length > 0) {
titleParts.push(`🚨 ${emptyMeds.length} ${tr.push.empty || "Empty"}`);
}
if (lowMeds.length > 0) {
titleParts.push(`⚠️ ${lowMeds.length} ${tr.push.low || "Low"}`);
if (criticalMeds.length > 0) {
titleParts.push(`🚨 ${criticalMeds.length} ${tr.push.critical || "Critical"}`);
}
const title = `MedAssist: ${titleParts.join(", ")} - ${tr.push.reorderNow || "Reorder Now!"}`;
// Build clear message with sections
const messageParts: string[] = [];
if (emptyMeds.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection || "EMPTY (reorder immediately)"}:`);
messageParts.push(`🚨 ${tr.push.emptySection || "Empty (reorder immediately)"}:`);
emptyMeds.forEach((m) => messageParts.push(`${m.name}`));
}
if (lowMeds.length > 0) {
if (criticalMeds.length > 0) {
if (emptyMeds.length > 0) messageParts.push("");
messageParts.push(`⚠️ ${tr.push.lowSection || "RUNNING LOW (reorder soon)"}:`);
lowMeds.forEach((m) =>
messageParts.push(`🚨 ${tr.push.criticalSection || "Running critically low"}:`);
criticalMeds.forEach((m) =>
messageParts.push(
`${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`
)
@@ -343,7 +355,7 @@ async function checkAndSendReminderForUser(
messageParts.push(tr.push.repeatDailyNote);
}
const message = messageParts.join("\n");
const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
@@ -377,7 +389,7 @@ async function checkAndSendReminderForUser(
let schedulerTimeout: NodeJS.Timeout | null = null;
function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
function scheduleNextCheck(logger: ServiceLogger): void {
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
const nextTime = getNextScheduledTime(REMINDER_HOUR);
@@ -388,7 +400,7 @@ function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: s
nextScheduledCheck: nextTime.toISOString(),
});
logger.info(
logger.debug(
`[Reminder] Next check scheduled for ${formatInTimezone(nextTime)} (${getTimezone()}) (in ${Math.round(msUntilNext / 1000 / 60)} minutes)`
);
@@ -399,7 +411,7 @@ function scheduleNextCheck(logger: { info: (msg: string) => void; error: (msg: s
}, msUntilNext);
}
export function startReminderScheduler(logger: { info: (msg: string) => void; error: (msg: string) => void }): void {
export function startReminderScheduler(logger: ServiceLogger): void {
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
// Check if we need to run immediately (missed today's check)
+4
View File
@@ -126,11 +126,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+90
View File
@@ -121,11 +121,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -1181,6 +1185,92 @@ describe("Integration Tests", () => {
expect(data[0].plannerUsage).toBe(10);
expect(data[0].enough).toBe(true); // 45 > 10
});
it("should use user-selected start date, not current time (fix asymmetric counting)", async () => {
// Regression test: When a planner range starts today, the old code used
// max(now, start) as the effective start. If now was between the morning
// dose (07:00) and evening dose (20:00), morning was skipped but evening
// counted, giving an asymmetric result (e.g., 5 instead of 6).
//
// Example: medication with daily morning (07:00) + evening (20:00) intakes,
// planner range [today 01:00, today+3 01:00).
// Old code at 15:00: morning 07:00 < 15:00 → skipped, evening 20:00 ≥ 15:00 → counted
// Result: 2 morning + 3 evening = 5 instead of 3+3 = 6.
// Use a past start date so the intakes predate the planner range
const intakeStart = "2025-01-01T07:00:00.000Z";
const intakeEvening = "2025-01-01T20:00:00.000Z";
// Plan range: Feb 9 00:00 to Feb 12 00:00 UTC (3 full days)
const planStart = "2026-02-09T00:00:00.000Z";
const planEnd = "2026-02-12T00:00:00.000Z";
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Twice Daily Med Asymmetric",
packCount: 5,
blistersPerPack: 5,
pillsPerBlister: 10,
blisters: [
{ usage: 1, every: 1, start: intakeStart },
{ usage: 1, every: 1, start: intakeEvening },
],
},
});
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: planStart,
endDate: planEnd,
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
// Both morning and evening should have exactly 3 occurrences each
// (Feb 9, 10, 11) for a total of 6, regardless of current time
expect(data[0].plannerUsage).toBe(6);
});
it("should handle planner range starting before blister start", async () => {
// Blister starts on Feb 10, planner range starts Feb 9
// Should only count doses from Feb 10 onwards
const intakeMorning = "2026-02-10T07:00:00.000Z";
const intakeEvening = "2026-02-10T20:00:00.000Z";
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Recent Start Med",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
blisters: [
{ usage: 1, every: 1, start: intakeMorning },
{ usage: 1, every: 1, start: intakeEvening },
],
},
});
const response = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: "2026-02-09T00:00:00.000Z",
endDate: "2026-02-12T00:00:00.000Z",
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
// Only Feb 10 and Feb 11 have doses (blister starts Feb 10)
expect(data[0].plannerUsage).toBe(4); // 2 days × 2 intakes
});
});
// ---------------------------------------------------------------------------
+306 -25
View File
@@ -110,11 +110,15 @@ async function createSchema(client: Client) {
expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
@@ -161,21 +165,6 @@ describe("Planner Routes", () => {
});
describe("POST /planner/send-email", () => {
it("should reject request with missing email", async () => {
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
from: "2025-01-01",
until: "2025-01-31",
rows: [{ medicationName: "Test", totalPills: 10, plannerUsage: 5, enough: true }],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing email or planner data" });
});
it("should reject request with missing rows", async () => {
const response = await app.inject({
method: "POST",
@@ -189,10 +178,16 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing email or planner data" });
expect(response.json()).toEqual({ error: "Missing planner data" });
});
it("should reject when SMTP is not configured", async () => {
it("should return error when no notification channels configured", async () => {
// User settings exist but email/shoutrrr disabled
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 0, 0, 'en')`,
args: [999999999],
});
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
@@ -217,7 +212,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "SMTP not configured" });
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should send email successfully when SMTP is configured", async () => {
@@ -226,6 +221,12 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
// Enable email in user settings
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -253,7 +254,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Email sent successfully" });
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
// Cleanup
@@ -267,6 +268,11 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -308,7 +314,7 @@ describe("Planner Routes", () => {
// Check that HTML contains out of stock warning
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.html).toContain("Out of Stock");
expect(mailCall.html).toContain("Empty");
expect(mailCall.html).toContain("1 medication");
delete process.env.SMTP_HOST;
@@ -321,6 +327,11 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockRejectedValueOnce(new Error("Connection refused"));
const response = await app.inject({
@@ -347,7 +358,7 @@ describe("Planner Routes", () => {
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toContain("Failed to send email");
expect(response.json().error).toContain("Email:");
expect(response.json().error).toContain("Connection refused");
delete process.env.SMTP_HOST;
@@ -360,6 +371,12 @@ describe("Planner Routes", () => {
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
// User settings with German language
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'de')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
@@ -390,12 +407,178 @@ describe("Planner Routes", () => {
// German date format should be used
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Supply Overview");
expect(mailCall.subject).toContain("Bestandsübersicht");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send push notification when shoutrrr is enabled", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via push" });
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
// Verify push message contains medication info
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
expect(title).toContain("Supply Overview");
expect(message).toContain("Aspirin");
});
it("should send both email and push when both enabled", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 1, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 5,
plannerUsage: 30,
blisterSize: 10,
blistersNeeded: 3,
fullBlisters: 0,
loosePills: 5,
enough: false,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via email and push" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
expect(mockSendShoutrrr).toHaveBeenCalledTimes(1);
// Verify push message contains out of stock info
const [_url, _title, message] = mockSendShoutrrr.mock.calls[0];
expect(message).toContain("Aspirin");
expect(message).toContain("Empty");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send push with German translations", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'de')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 5,
plannerUsage: 30,
blisterSize: 10,
blistersNeeded: 3,
fullBlisters: 0,
loosePills: 5,
enough: false,
},
],
},
});
expect(response.statusCode).toBe(200);
// Check German translations in push
const [_url, title] = mockSendShoutrrr.mock.calls[0];
expect(title).toContain("Bestandsübersicht");
});
it("should handle push error gracefully", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: false, error: "Connection failed" });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
from: "2025-01-01",
until: "2025-01-31",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(500);
expect(response.json().error).toContain("Push:");
expect(response.json().error).toContain("Connection failed");
});
});
describe("POST /reminder/send-email", () => {
@@ -503,10 +686,10 @@ describe("Planner Routes", () => {
expect(response.statusCode).toBe(200);
// Check email contains EMPTY warning
// Check email contains empty warning
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Empty");
expect(mailCall.html).toContain("EMPTY");
expect(mailCall.html).toContain("empty");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
@@ -541,7 +724,7 @@ describe("Planner Routes", () => {
const mailCall = mockSendMail.mock.calls[0][0];
expect(mailCall.subject).toContain("Empty");
expect(mailCall.subject).toContain("Running Low");
expect(mailCall.subject).toContain("Critical");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
@@ -698,5 +881,103 @@ describe("Planner Routes", () => {
expect(response.json().error).toContain("Push:");
expect(response.json().error).toContain("Network error");
});
it("should differentiate critical and low stock in push notification", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true },
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
],
},
});
expect(response.statusCode).toBe(200);
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
// Title should contain both Critical and Low labels
expect(title).toContain("Critical");
expect(title).toContain("Low");
// Message should have separate sections
expect(message).toContain("Running critically low");
expect(message).toContain("Aspirin");
expect(message).toContain("Running low");
expect(message).toContain("Ibuprofen");
});
it("should differentiate critical and low stock in email", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123" });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [
{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03", isCritical: true },
{ name: "Ibuprofen", medsLeft: 49, daysLeft: 24, depletionDate: "2025-01-24", isCritical: false },
],
},
});
expect(response.statusCode).toBe(200);
const mailCall = mockSendMail.mock.calls[0][0];
// Subject should contain both Critical and Low
expect(mailCall.subject).toContain("Critical");
expect(mailCall.subject).toContain("Low");
// HTML should have separate alert boxes
expect(mailCall.html).toContain("critically low");
expect(mailCall.html).toContain("running low");
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should label all meds as critical when isCritical not provided", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, shoutrrr_url, language) VALUES (?, 0, 1, 'ntfy://localhost/test', 'en')`,
args: [999999999],
});
mockSendShoutrrr.mockResolvedValueOnce({ success: true });
const response = await app.inject({
method: "POST",
url: "/reminder/send-email",
payload: {
email: "test@example.com",
lowStock: [{ name: "Aspirin", medsLeft: 5, daysLeft: 3, depletionDate: "2025-01-03" }],
},
});
expect(response.statusCode).toBe(200);
const [_url, title, message] = mockSendShoutrrr.mock.calls[0];
// Should be treated as critical (backwards compat)
expect(title).toContain("Critical");
expect(title).not.toContain("Low");
expect(message).toContain("Running critically low");
});
});
});
+66 -2
View File
@@ -51,6 +51,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: 90,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
};
}
@@ -76,6 +77,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays: s.expiry_warning_days,
language: s.language,
stockCalculationMode: s.stock_calculation_mode,
shareStockStatus: Boolean(s.share_stock_status ?? 1),
};
});
@@ -102,6 +104,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiryWarningDays?: number;
language?: string;
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
}>("/settings", async (request, reply) => {
const userId = 1;
@@ -150,8 +153,8 @@ async function registerSettingsRoutes(ctx: TestContext) {
reminder_days_before, repeat_daily_reminders, skip_reminders_for_taken_doses,
repeat_reminders_enabled, reminder_repeat_interval_minutes, max_nagging_reminders,
low_stock_days, normal_stock_days, high_stock_days,
expiry_warning_days, language, stock_calculation_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expiry_warning_days, language, stock_calculation_mode, share_stock_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
body.emailEnabled ? 1 : 0,
@@ -174,6 +177,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
],
});
} else {
@@ -200,6 +204,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
expiry_warning_days = ?,
language = ?,
stock_calculation_mode = ?,
share_stock_status = ?,
updated_at = strftime('%s','now')
WHERE user_id = ?`,
args: [
@@ -223,6 +228,7 @@ async function registerSettingsRoutes(ctx: TestContext) {
body.expiryWarningDays ?? 90,
body.language || "en",
body.stockCalculationMode || "automatic",
body.shareStockStatus !== false ? 1 : 0,
userId,
],
});
@@ -542,6 +548,64 @@ describe("Settings API", () => {
});
});
// ---------------------------------------------------------------------------
// Share Stock Status
// ---------------------------------------------------------------------------
describe("Share Stock Status", () => {
it("should default to true (show stock on shared links)", async () => {
const response = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(true);
});
it("should disable share stock status", async () => {
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(false);
});
it("should re-enable share stock status", async () => {
// Disable first
await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: false },
});
// Re-enable
const response = await ctx.app.inject({
method: "PUT",
url: "/settings",
payload: { shareStockStatus: true },
});
expect(response.statusCode).toBe(200);
const getResponse = await ctx.app.inject({
method: "GET",
url: "/settings",
});
expect(getResponse.json().shareStockStatus).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Repeat Reminders & Skip Reminders Settings
// ---------------------------------------------------------------------------
+12 -5
View File
@@ -216,13 +216,14 @@ export interface UpdateUserSettingsOptions {
userId: number;
stockCalculationMode?: "automatic" | "manual";
lowStockDays?: number;
shareStockStatus?: boolean;
}
/**
* Create or update user settings
*/
export async function setUserSettings(client: Client, options: UpdateUserSettingsOptions): Promise<void> {
const { userId, stockCalculationMode = "automatic", lowStockDays = 30 } = options;
const { userId, stockCalculationMode = "automatic", lowStockDays = 30, shareStockStatus } = options;
// Check if settings exist
const existing = await client.execute({
@@ -232,13 +233,19 @@ export async function setUserSettings(client: Client, options: UpdateUserSetting
if (existing.rows.length > 0) {
await client.execute({
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ? WHERE user_id = ?`,
args: [stockCalculationMode, lowStockDays, userId],
sql: `UPDATE user_settings SET stock_calculation_mode = ?, low_stock_days = ?${shareStockStatus !== undefined ? ", share_stock_status = ?" : ""} WHERE user_id = ?`,
args:
shareStockStatus !== undefined
? [stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0, userId]
: [stockCalculationMode, lowStockDays, userId],
});
} else {
await client.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days) VALUES (?, ?, ?)`,
args: [userId, stockCalculationMode, lowStockDays],
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, low_stock_days${shareStockStatus !== undefined ? ", share_stock_status" : ""}) VALUES (?, ?, ?${shareStockStatus !== undefined ? ", ?" : ""})`,
args:
shareStockStatus !== undefined
? [userId, stockCalculationMode, lowStockDays, shareStockStatus ? 1 : 0]
: [userId, stockCalculationMode, lowStockDays],
});
}
}
+45
View File
@@ -10,6 +10,7 @@ import {
createTestMedication,
createTestShareToken,
createTestUser,
setUserSettings,
type TestContext,
} from "./setup.js";
@@ -141,6 +142,14 @@ async function registerShareRoutes(ctx: TestContext) {
const lowStockDays = settingsResult.rows.length > 0 ? (settingsResult.rows[0].low_stock_days as number) : 30;
// Get shareStockStatus setting
const shareStockResult = await client.execute({
sql: `SELECT share_stock_status FROM user_settings WHERE user_id = ?`,
args: [share.user_id],
});
const shareStockStatus =
shareStockResult.rows.length > 0 ? Boolean(shareStockResult.rows[0].share_stock_status ?? 1) : true;
return {
takenBy: share.taken_by,
sharedBy: share.owner_username,
@@ -149,6 +158,7 @@ async function registerShareRoutes(ctx: TestContext) {
stockThresholds: {
lowStockDays,
},
shareStockStatus,
};
});
@@ -421,6 +431,41 @@ describe("Share Link API", () => {
expect(med.blisters).toHaveLength(1);
expect(med.blisters[0].usage).toBe(1);
expect(med.blisters[0].every).toBe(1);
// shareStockStatus should default to true
expect(data.shareStockStatus).toBe(true);
});
it("should respect shareStockStatus setting when disabled", async () => {
// Create medication
await createTestMedication(ctx.client, {
userId,
name: "TestMed",
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
});
// Set shareStockStatus to false
await setUserSettings(ctx.client, { userId, shareStockStatus: false });
// Create share token
const token = await createTestShareToken(ctx.client, {
userId,
takenBy: "Daniel",
scheduleDays: 30,
});
const response = await ctx.app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.json().shareStockStatus).toBe(false);
});
it("should return 404 for invalid token", async () => {
+4 -4
View File
@@ -69,8 +69,8 @@ describe("Translations Module", () => {
});
it("should replace multiple placeholders", () => {
const result = t("{count} {type} running low", { count: 3, type: "medications" });
expect(result).toBe("3 medications running low");
const result = t("{count} {type} running critically low", { count: 3, type: "medications" });
expect(result).toBe("3 medications running critically low");
});
it("should replace same placeholder multiple times", () => {
@@ -98,7 +98,7 @@ describe("Translations Module", () => {
// Stock reminder subject
const subject = t(translations.stockReminder.subject, { count: 3, s: "s" });
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Low");
expect(subject).toBe("MedAssist-ng Auto-Reminder: 3 Medications Running Critically Low");
// Intake reminder description
const description = t(translations.intakeReminder.description, { minutes: 30 });
@@ -113,7 +113,7 @@ describe("Translations Module", () => {
const translations = getTranslations("de");
const subject = t(translations.stockReminder.subject, { count: 2, e: "e" });
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente wird knapp");
expect(subject).toBe("MedAssist-ng Auto-Erinnerung: 2 Medikamente kritisch niedrig");
const takenBy = t(translations.intakeReminder.takenBy, { name: "Daniel" });
expect(takenBy).toBe("für Daniel");
+46
View File
@@ -0,0 +1,46 @@
/**
* Simple startup logger that respects LOG_LEVEL environment variable.
* Used for code that runs before Fastify is initialized (db/client.ts, migrations).
* Once Fastify is running, use app.log instead.
*/
const LOG_LEVELS: Record<string, number> = {
silent: 60,
fatal: 60,
error: 50,
warn: 40,
info: 30,
debug: 20,
trace: 10,
};
function getLevel(): number {
const envLevel = (process.env.LOG_LEVEL || "info").toLowerCase();
return LOG_LEVELS[envLevel] ?? LOG_LEVELS.info;
}
function shouldLog(level: string): boolean {
return LOG_LEVELS[level] >= getLevel();
}
export const log = {
debug(msg: string): void {
if (shouldLog("debug")) console.log(msg);
},
info(msg: string): void {
if (shouldLog("info")) console.log(msg);
},
warn(msg: string): void {
if (shouldLog("warn")) console.warn(msg);
},
error(msg: string): void {
if (shouldLog("error")) console.error(msg);
},
};
/** Logger interface for services that receive a logger from the caller */
export type ServiceLogger = {
info: (msg: string) => void;
debug: (msg: string) => void;
error: (msg: string) => void;
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
"version": "1.8.8",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.8.8",
"version": "1.9.0",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.8.8",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+117 -59
View File
@@ -195,6 +195,16 @@ export function MedDetailModal({
<span className={`med-detail-value ${textClass}`}>
{currentStock} /{" "}
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
{currentStock >
(selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize) && (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
)}
</span>
</div>
</div>
@@ -202,7 +212,10 @@ export function MedDetailModal({
{/* Package Details Section */}
<div className="med-detail-section">
<h3>{t("modal.packageDetails")}</h3>
<h3>
{t("modal.packageDetails")} (
{selectedMed.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")})
</h3>
<div className="med-detail-grid">
{selectedMed.packageType === "blister" ? (
<>
@@ -263,7 +276,12 @@ export function MedDetailModal({
</h3>
<div className="med-detail-schedules">
{selectedMed.blisters.map((blister, idx) => {
const personCount = Math.max(1, selectedMed.takenBy?.length || 1);
// When using new intakes format with per-intake takenBy,
// each intake already represents one person's dose — don't multiply.
// For legacy intakes (no per-intake takenBy), multiply by personCount.
const intake = selectedMed.intakes?.[idx];
const hasPerIntakeTakenBy = !!intake?.takenBy;
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
const totalUsage = blister.usage * personCount;
return (
<div key={idx} className="med-schedule-item">
@@ -347,10 +365,14 @@ export function MedDetailModal({
})}
</span>
<span className="refill-amount">
+
{entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded}{" "}
{t("common.pills")}
{(() => {
const total =
selectedMed.packageType === "bottle"
? entry.loosePillsAdded
: entry.packsAdded * selectedMed.blistersPerPack * selectedMed.pillsPerBlister +
entry.loosePillsAdded;
return `+${total} ${total === 1 ? t("common.pill") : t("common.pills")}`;
})()}
</span>
</div>
))}
@@ -405,24 +427,38 @@ export function MedDetailModal({
<p className="refill-med-name">{selectedMed.name}</p>
<div className="refill-form">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
{selectedMed.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
</div>
<div className="modal-footer">
@@ -437,12 +473,17 @@ export function MedDetailModal({
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
selectedMed.packageType === "blister"
? refillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
</div>
@@ -469,50 +510,67 @@ export function MedDetailModal({
{(() => {
const dbTotal = getMedTotal(selectedMed);
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const isBottle = selectedMed.packageType === "bottle";
const newTotal = isBottle
? editStockPartialBlisterPills
: editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
const difference = newTotal - currentTotal;
return (
<>
<div className="edit-stock-form">
<label>
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
{isBottle ? (
<label>
{t("editStock.totalPills")}
<input
type="number"
min="0"
value={editStockPartialBlisterPills}
onChange={(e) => onEditStockPartialBlisterPillsChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
) : (
<>
<label>
{t("editStock.fullBlisters")}{" "}
{t("editStock.pillsPerBlister", { count: selectedMed.pillsPerBlister })}
<input
type="number"
min="0"
value={editStockFullBlisters}
onChange={(e) => onEditStockFullBlistersChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("editStock.partialBlisterPills")}
<input
type="number"
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
max={selectedMed.pillsPerBlister}
value={editStockPartialBlisterPills}
onChange={(e) => {
const val = parseInt(e.target.value, 10) || 0;
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
const max = selectedMed.pillsPerBlister;
onEditStockPartialBlisterPillsChange(Math.max(min, Math.min(val, max)));
}}
/>
</label>
</>
)}
</div>
<div className="edit-stock-summary">
<div className="summary-row">
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal} {t("common.pills")}
{currentTotal} {currentTotal === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal} {t("common.pills")}
{newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
<div
@@ -521,7 +579,7 @@ export function MedDetailModal({
<span>{t("editStock.difference")}:</span>
<span>
{difference > 0 ? "+" : ""}
{difference} {t("common.pills")}
{difference} {Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}
</span>
</div>
</div>
+74 -46
View File
@@ -266,7 +266,8 @@ export function MobileEditModal({
)}
<div className="full">
<p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
</p>
</div>
<label className="full">
@@ -307,24 +308,38 @@ export function MobileEditModal({
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<button
type="button"
className="success"
@@ -333,12 +348,18 @@ export function MobileEditModal({
>
{refillSaving ? t("common.saving") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
)}
@@ -426,26 +447,29 @@ export function MobileEditModal({
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
<option value="">{t("form.blisters.takenByEveryone")}</option>
{existingPeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">🔔</span>
{form.takenBy.length === 0 ? null : (
<label className="compact full-row">
<span>{t("form.blisters.takenByIntake")}</span>
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span className="legend-hint">🔔</span>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
{form.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")}
@@ -453,7 +477,11 @@ export function MobileEditModal({
)}
</div>
))}
<button type="button" className="ghost add-blister" onClick={() => onAddIntake()}>
<button
type="button"
className="ghost add-blister"
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
</fieldset>
+313 -291
View File
@@ -12,6 +12,22 @@ import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
// =============================================================================
// Stock status helper — identical to DashboardPage's getStockStatus
// =============================================================================
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
thresholds: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
) {
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
if (daysLeft <= thresholds.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
if (daysLeft < thresholds.lowStockDays) return { className: "warning", label: "status.lowStock" };
if (daysLeft >= thresholds.highStockDays) return { className: "high", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
export function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
@@ -198,17 +214,6 @@ export function SharedSchedule() {
return doseId;
}
// Count taken doses for a day/item (simplified - per-intake takenBy means one person per dose)
function _countTakenDoses(doses: Array<{ id: string; takenBy: string | null }>): { total: number; taken: number } {
let total = 0;
let taken = 0;
for (const d of doses) {
total++;
if (takenDoses.has(d.id)) taken++;
}
return { total, taken };
}
async function markDoseTaken(doseId: string) {
// Optimistic update
setTakenDoses((prev) => {
@@ -419,96 +424,189 @@ export function SharedSchedule() {
return { todayDay: todayEntry || null, futureDays: future };
}, [schedule, data?.scheduleDays, i18n.language]);
// Build a map of medication name -> dismissedUntil date string
// This is robust against timestamp changes from schedule updates or timezone fixes
const dismissedUntilByMed = useMemo(() => {
if (!data) return new Map<string, string>();
const map = new Map<string, string>();
for (const med of data.medications) {
if (med.dismissedUntil) {
map.set(med.name, med.dismissedUntil);
}
}
return map;
}, [data]);
// Helper to check if a dose date is on or before the dismissedUntil date
function isDoseDismissedByName(doseTimestamp: number, medName: string): boolean {
const dismissedUntilDate = dismissedUntilByMed.get(medName);
if (!dismissedUntilDate) return false;
// Compare date strings (YYYY-MM-DD format sorts correctly)
const doseDate = new Date(doseTimestamp);
const doseDateStr = `${doseDate.getFullYear()}-${String(doseDate.getMonth() + 1).padStart(2, "0")}-${String(doseDate.getDate()).padStart(2, "0")}`;
return doseDateStr <= dismissedUntilDate;
}
// Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time
// Calculate coverage for stock status colors — matches main app's calculateCoverage logic
// Uses time-based automatic consumption (same as DashboardPage) for accurate stock levels
const { coverageByMed, depletionByMed } = useMemo(() => {
if (!data) return { coverageByMed: {}, depletionByMed: {} };
const MS_PER_DAY = 86_400_000;
const now = Date.now();
const calcMode = data.stockCalculationMode ?? "automatic";
const coverage: Record<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
// With per-intake takenBy, each dose.id is unique and already has person suffix if needed
const takenByMed: Record<string, number> = {};
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
if (takenDoses.has(dose.id)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
}
for (const med of data.medications) {
const totalCount = getMedTotal(med);
const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from intakes (or blisters for legacy)
const intakes = med.intakes || med.blisters;
const dailyUsage = intakes.reduce((sum, b) => sum + b.usage / b.every, 0);
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
const intakes = med.intakes || med.blisters.map((b) => ({ ...b, takenBy: null as string | null }));
const blisters = med.blisters;
// Calculate depletion time (when medication will run out)
if (dailyUsage > 0 && currentCount > 0) {
const daysUntilEmpty = currentCount / dailyUsage;
depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000;
} else if (currentCount <= 0) {
depletion[med.name] = Date.now(); // Already empty
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
if (intake.takenBy) uniquePeople.add(intake.takenBy);
});
med.takenBy?.forEach((person) => uniquePeople.add(person));
const personCount = Math.max(1, uniquePeople.size || med.takenBy?.length || 1);
// Calculate daily consumption rate accounting for per-intake takenBy
let dailyRate = 0;
blisters.forEach((s, idx) => {
const baseRate = s.every > 0 ? s.usage / s.every : 0;
const intake = intakes[idx];
if (intake?.takenBy) {
dailyRate += baseRate; // Per-intake takenBy: 1 person
} else {
dailyRate += baseRate * personCount; // Legacy: all people
}
});
let consumed = 0;
const stockCorrectionCutoff = med.lastStockCorrectionAt ? med.lastStockCorrectionAt : 0;
if (calcMode === "automatic") {
// Time-based: every scheduled dose counts as consumed once its time has passed
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart)) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null];
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
// Early intakes: future doses already marked as taken
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoses) {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (medId === med.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += s.usage;
}
}
}
consumed += timeBasedConsumed + earlyTakenConsumed;
});
} else {
depletion[med.name] = null; // No usage schedule
// Manual mode: only count explicitly taken doses
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === med.id && blisters[blisterIdx]) {
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
const afterCorrection = stockCorrectionCutoff === 0 || doseTimestamp > stockCorrectionCutoff;
if (!Number.isNaN(blisterStartDateOnly) && doseTimestamp >= blisterStartDateOnly && afterCorrection) {
consumed += blisters[blisterIdx].usage;
}
}
}
});
}
const totalPills = getMedTotal(med);
const medsLeft = Math.max(0, totalPills - consumed);
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
coverage[med.name] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[med.name] = depletionMs;
}
return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, schedule, takenDoses]);
}, [data, takenDoses]);
// Stock thresholds from user settings (provided by API) or defaults
const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30;
// Stock thresholds from API — matches DashboardPage's StockThresholds type exactly
const stockThresholds = useMemo(
() => ({
lowStockDays: data?.stockThresholds?.lowStockDays ?? 30,
normalStockDays: data?.stockThresholds?.normalStockDays ?? 60,
highStockDays: data?.stockThresholds?.highStockDays ?? 90,
criticalStockDays: data?.stockThresholds?.reminderDaysBefore ?? 7,
expiryWarningDays: data?.stockThresholds?.expiryWarningDays ?? 90,
}),
[data]
);
// Get worst stock status for a day's medications (matches main app logic with depletion)
const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => {
// Get worst stock status for a day's medications — identical to DashboardPage
function getDayStockStatus(meds: { medName: string; lastWhen: number }[]) {
const statuses = meds.map((item) => {
const coverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
// Will be out of stock by this day?
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) {
return "danger";
}
if (typeof depletionTime === "number" && item.lastWhen > depletionTime) return "danger";
if (!coverage) return "success";
const { daysLeft, medsLeft } = coverage;
// Currently out of stock
if (medsLeft <= 0 || daysLeft === 0) return "danger";
// No schedule (can't calculate)
if (daysLeft === null) return "success";
// Low stock: < lowStockDays (warning)
if (daysLeft < lowStockDays) return "warning";
// Normal/High stock
return "success";
const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds);
return status.className;
});
return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success";
};
}
// Whether to show stock status indicators on the shared schedule
const showStock = data?.shareStockStatus !== false;
// Helper: check if a dose is "done" (taken, per-dose dismissed, or med-level dismissed)
function isDoseIdDone(doseId: string): boolean {
if (takenDoses.has(doseId)) return true;
if (dismissedDoses.has(doseId)) return true;
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) {
return true;
}
}
}
return false;
}
// Missed past dose IDs — matches DashboardPage's missedPastDoseIds logic
const missedPastDoseIds = useMemo(() => {
const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return allPastDoseIds.filter((id) => !isDoseIdDone(id));
}, [pastDays, takenDoses, dismissedDoses, data]);
if (loading) {
return (
@@ -631,94 +729,54 @@ export function SharedSchedule() {
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : (
<>
{/* Past days toggle */}
{/* Past days toggle — identical to DashboardPage */}
{pastDays.length > 0 &&
(() => {
// Count all past doses (for display)
// With per-intake takenBy, each dose.id is unique
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
// Count missed doses (not taken AND not dismissed AND not from previous schedule)
// Check: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false;
// Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(id)) return false;
// Check if dismissed via medication-level dismissedUntil date
const parts = id.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(id, med.dismissedUntil ?? undefined)) {
return false; // dismissed = not missed
}
}
}
return true; // not taken, not dismissed = missed
}).length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedPastDoses > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })}
>
{missedPastDoses}
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
) : null}
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
</div>
);
})()}
{/* Past days (when expanded) */}
{/* Past days (when expanded) — identical to DashboardPage */}
{showPastDays &&
pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken or dismissed)
// Checks: per-dose dismissed flag and medication-level dismissedUntil
const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true;
// Check if this dose is dismissed via per-dose flag from API
if (dismissedDoses.has(doseId)) return true;
// Check if dismissed via medication-level dismissedUntil date
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId);
if (med) {
if (isDoseDismissed(doseId, med.dismissedUntil ?? undefined)) {
return true;
}
}
}
return false;
};
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
const doneCount = allDoseIds.filter(isDoseIdDone).length;
const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayDone ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -728,18 +786,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayDone ? (
{allDayTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - doneCount })}
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
<span className="day-progress">
{doneCount}/{allDoseIds.length}
{takenCount}/{allDoseIds.length}
</span>
</>
)}
@@ -749,61 +807,48 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
// A dose is "done" if taken OR dismissed
const allDone = itemDoseIds.every(isDoseIdDone);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
<div
key={`${day.dateStr}-${item.medName}`}
className={`time-row ${allDone ? "taken" : ""}`}
className={`time-row ${allTaken ? "taken" : ""}`}
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
// Check: medication-level dismissedUntil and per-dose dismissed flag
const isMedLevelDismissed = isDoseDismissedByName(dose.when, dose.medName);
const isTaken = takenDoses.has(dose.id);
const isPerDoseDismissed = dismissedDoses.has(dose.id);
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
return (
<div key={dose.id} className={`dose-item past ${isDone ? "all-taken" : ""}`}>
<div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -811,26 +856,16 @@ export function SharedSchedule() {
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
<div className={`dose-person ${isDone ? "taken" : ""}`}>
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isDone ? (
isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
</button>
) : (
// Dismissed - show checkmark but no undo
<span
className="dose-btn dismissed"
title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
>
</span>
)
{isTaken ? (
<button
className="dose-btn undo"
onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
>
</button>
) : (
<button
className="dose-btn take"
@@ -871,7 +906,7 @@ export function SharedSchedule() {
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today stock-${worstStatus}`}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} today stock-${showStock ? worstStatus : "success"}`}
>
<div
className="day-divider clickable"
@@ -894,23 +929,16 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -921,20 +949,20 @@ export function SharedSchedule() {
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
@@ -942,7 +970,10 @@ export function SharedSchedule() {
const isTaken = takenDoses.has(dose.id);
const isOverdue = dose.when < Date.now() && !isTaken;
return (
<div key={dose.id} className={`dose-item ${isTaken ? "all-taken" : ""}`}>
<div
key={dose.id}
className={`dose-item ${isOverdue ? "overdue" : ""} ${isTaken ? "all-taken" : ""}`}
>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -985,43 +1016,55 @@ export function SharedSchedule() {
);
})()}
{/* Future days toggle */}
{futureDays.length > 0 && (
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays ? t("dashboard.schedules.hideFutureDays") : t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
</div>
)}
{/* Future days toggle — identical to DashboardPage */}
{futureDays.length > 0 &&
(() => {
const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => m.doses.map((dose) => dose.id))
);
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
return (
<div className="future-days-header">
<div
className={`future-days-toggle ${showFutureDays ? "expanded" : ""}`}
onClick={() => setShowFutureDays(!showFutureDays)}
>
<span className="future-days-icon">{showFutureDays ? "▼" : "▶"}</span>
<span className="future-days-label">
{showFutureDays
? t("dashboard.schedules.hideFutureDays")
: t("dashboard.schedules.showFutureDays")}
</span>
<span className="future-days-count">
({t("dashboard.schedules.futureDaysCount", { count: futureDays.length })})
</span>
{takenFutureDoses > 0 && totalFutureDoses.length > 0 && (
<span className="future-days-progress">
{takenFutureDoses}/{totalFutureDoses.length}
</span>
)}
</div>
</div>
);
})()}
{/* Future days (when expanded) */}
{/* Future days (when expanded) — identical to DashboardPage */}
{showFutureDays &&
futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
// Determine if day should be collapsed (auto-collapsed by default, manual override)
const isAutoCollapsed = allDayTaken;
// Future days: collapsed by default, manual override to expand
const isAutoCollapsed = true;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
const isCollapsed = !isManuallyExpanded;
return (
<div
key={day.dateStr}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${showStock ? worstStatus : "success"}`}
>
<div
className="day-divider clickable"
@@ -1044,24 +1087,15 @@ export function SharedSchedule() {
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
// Calculate status for this medication on this day
let status: { className: string; label: string } | null = null;
if (willBeOutOfStock) {
status = { className: "danger", label: "status.outOfStock" };
} else if (medCoverage) {
const { daysLeft, medsLeft } = medCoverage;
if (medsLeft <= 0 || daysLeft === 0) {
status = { className: "danger", label: "status.outOfStock" };
} else if (daysLeft !== null && daysLeft < lowStockDays) {
status = { className: "warning", label: "status.lowStock" };
} else {
status = { className: "success", label: "status.normal" };
}
}
const status = showStock
? willBeOutOfStock
? { className: "danger", label: "status.outOfStock" }
: medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null
: null;
const itemDoseIds = item.doses.map((d) => d.id);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
@@ -1072,37 +1106,27 @@ export function SharedSchedule() {
>
<div className="time-main">
<div className="med-name">
<span
className={med?.imageUrl ? "clickable" : ""}
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
>
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
</span>
</div>
<span className="med-name-text">{item.medName}</span>
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
{item.doses.map((dose) => {
const isTaken = takenDoses.has(dose.id);
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return (
<div
key={dose.id}
className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "all-taken" : ""}`}
>
<div key={dose.id} className={`dose-item future ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
@@ -1110,9 +1134,7 @@ export function SharedSchedule() {
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span>
<div className="dose-checks">
<div
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
>
<div className={`dose-person ${isTaken ? "taken" : ""}`}>
{dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
{isTaken ? (
<button
@@ -1127,7 +1149,7 @@ export function SharedSchedule() {
className="dose-btn take"
onClick={() => markDoseTaken(dose.id)}
title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
disabled={true}
>
</button>
+2 -1
View File
@@ -66,7 +66,8 @@ export function UserFilterModal({
</div>
<div className="user-med-stats">
<span className="user-med-pills">
{currentStock}/{formatNumber(packageSize)} {t("common.pills")}
{currentStock}/{formatNumber(packageSize)}{" "}
{packageSize === 1 ? t("common.pill") : t("common.pills")}
</span>
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
</div>
+3 -1
View File
@@ -615,7 +615,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settings.repeatRemindersEnabled !== savedSettings.repeatRemindersEnabled ||
settings.reminderRepeatIntervalMinutes !== savedSettings.reminderRepeatIntervalMinutes ||
settings.maxNaggingReminders !== savedSettings.maxNaggingReminders ||
settings.stockCalculationMode !== savedSettings.stockCalculationMode
settings.stockCalculationMode !== savedSettings.stockCalculationMode ||
settings.shareStockStatus !== savedSettings.shareStockStatus ||
settings.expiryWarningDays !== savedSettings.expiryWarningDays
);
}, [settingsHook.settings, settingsHook.savedSettings]);
+15
View File
@@ -30,6 +30,9 @@ export interface Settings {
lastNotificationChannel: "email" | "push" | "both" | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: "email" | "push" | "both" | null;
lastStockReminderMedNames: string | null;
shoutrrrEnabled: boolean;
shoutrrrUrl: string;
emailStockReminders: boolean;
@@ -37,6 +40,7 @@ export interface Settings {
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
expiryWarningDays: number;
}
@@ -65,6 +69,9 @@ const defaultSettings: Settings = {
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
@@ -72,6 +79,7 @@ const defaultSettings: Settings = {
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
};
@@ -141,6 +149,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
}));
setSavedSettings((prev) => ({
...prev,
@@ -149,6 +160,9 @@ export function useSettings(): UseSettingsReturn {
lastNotificationChannel: data.lastNotificationChannel ?? prev.lastNotificationChannel,
lastReminderMedName: data.lastReminderMedName ?? prev.lastReminderMedName,
lastReminderTakenBy: data.lastReminderTakenBy ?? prev.lastReminderTakenBy,
lastStockReminderSent: data.lastStockReminderSent ?? prev.lastStockReminderSent,
lastStockReminderChannel: data.lastStockReminderChannel ?? prev.lastStockReminderChannel,
lastStockReminderMedNames: data.lastStockReminderMedNames ?? prev.lastStockReminderMedNames,
}));
})
.catch(() => {});
@@ -198,6 +212,7 @@ export function useSettings(): UseSettingsReturn {
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
language: i18n.language,
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
+42 -20
View File
@@ -21,11 +21,11 @@
"badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.",
"lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
"lowWarning": "Genug Vorrat, aber {{meds}} ist kritisch niedrig.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} sind kritisch niedrig.",
"lowWarningPrefix": "Genug Vorrat, aber",
"lowWarningSuffix": "wird knapp.",
"lowWarningSuffix_other": "werden knapp.",
"lowWarningSuffix": "ist kritisch niedrig.",
"lowWarningSuffix_other": "sind kritisch niedrig.",
"sendReminder": "🔔 Erinnerung jetzt senden"
},
"overview": {
@@ -59,10 +59,11 @@
"reminders": {
"active": "Automatische Erinnerungen aktiv",
"status": "Status",
"allStockOk": "Bestand OK",
"allOk": "Alles OK",
"allStockOk": "Bestand gut",
"allOk": "Alles gut",
"lastReminder": "Letzte Einnahme-Erinnerung",
"lastSent": "Letzte Einnahme-Erinnerung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"next": "Nachbestell-Erinnerung",
"nextIn": "Nachbestell-Erinnerung",
"inDays": "in {{days}} Tagen",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} Medikamente nachfüllen",
"emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp",
"lowWarning_other": "{{count}} Medikamente werden knapp",
"lowWarning": "{{count}} Medikament kritisch niedrig",
"lowWarning_other": "{{count}} Medikamente kritisch niedrig",
"waitingFirstCheck": "Warte auf erste Prüfung",
"type": "Typ",
"typeStock": "Bestand",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose",
"total": "Gesamt",
"stock": "Bestand"
"stock": "Bestand",
"totalCapacity": "Kapazität",
"type": "Typ"
}
},
"form": {
@@ -135,7 +138,7 @@
"takenBy": "Eingenommen von",
"packageType": "Verpackungsart",
"packageTypeBlister": "Blisterpackung",
"packageTypeBottle": "Pillendose / Behälter",
"packageTypeBottle": "Pillendose",
"packs": "Packungen",
"blistersPerPack": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
@@ -181,6 +184,7 @@
"calculate": "Berechnen",
"calculating": "Wird berechnet...",
"sendEmail": "📧 Per E-Mail senden",
"sendNotification": "🔔 Bedarf senden",
"table": {
"medication": "Medikament",
"usage": "Verbrauch",
@@ -229,25 +233,34 @@
"intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit",
"nextCheck": "Nächste Bestandsprüfung",
"lastSent": "Zuletzt gesendet",
"lastSent": "Letzte Benachrichtigung",
"lastStockSent": "Letzte Bestands-Erinnerung",
"lastIntakeSent": "Letzte Einnahme-Erinnerung",
"envHint": "Diese Werte können über REMINDER_HOUR und REMINDER_MINUTES_BEFORE in .env konfiguriert werden"
},
"stock": {
"title": "Bestand",
"threshold": "Erinnerungsschwelle",
"remindWhen": "Erinnern wenn Vorrat unter",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand niedrig ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen.",
"calculationMode": "Bestandsberechnung",
"automatic": "Automatisch",
"automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert",
"manual": "Manuell",
"manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden",
"display": "Anzeige",
"lowStockDays": "Niedriger Bestand (Tage)",
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
"highStockDays": "Hoher Bestand (Tage)",
"highStockTooltip": "Grün mit Stern ab diesem Schwellenwert"
"thresholds": "Schwellenwerte",
"criticalStockDays": "Kritisch (Tage)",
"criticalStockTooltip": "Bestand unter diesem Wert ist kritisch und erfordert sofortige Aufmerksamkeit",
"lowStockDays": "Niedrig (Tage)",
"lowStockTooltip": "Bestand unter diesem Wert bedeutet, dass bald nachbestellt werden sollte",
"highStockDays": "Hoch (Tage)",
"highStockTooltip": "Bestand über diesem Wert bedeutet, dass du gut versorgt bist",
"thresholdValidation": "Werte müssen sein: Kritisch < Niedrig < Hoch",
"shareStockStatus": "Bestand auf geteilten Links anzeigen",
"shareStockStatusDesc": "Bestandsstatus (Normal/Niedrig/Kritisch) und farbige Rahmen auf geteilten Zeitplan-Links für Einnahme-Nutzer anzeigen"
},
"stockReminder": {
"title": "Bestands-Erinnerung",
"description": "Benachrichtigung wenn Medikamentenbestand erreicht",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand kritisch ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen."
},
"saveSettings": "Einstellungen speichern"
},
@@ -288,6 +301,7 @@
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln",
"darkMode": "Zum dunklen Modus wechseln"
},
@@ -348,6 +362,9 @@
"common": {
"loading": "Wird geladen...",
"sending": "Wird gesendet...",
"sent": "Gesendet!",
"sendFailed": "Senden fehlgeschlagen",
"networkError": "Netzwerkfehler",
"saving": "Wird gespeichert...",
"unsavedChanges": {
"title": "Ungespeicherte Änderungen",
@@ -386,6 +403,9 @@
"fullBlisters": "volle Blister",
"inBlister": "in 1 Blister",
"total": "gesamt",
"pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max"
},
"share": {
@@ -450,6 +470,7 @@
"refill": {
"title": "Nachfüllen",
"packs": "Packungen hinzufügen",
"pillsToAdd": "Tabletten hinzufügen",
"loosePills": "Lose Tabletten hinzufügen",
"pillsPerPack": "1 Packung = {{count}} Tabletten",
"addToStock": "Zum Bestand hinzufügen",
@@ -466,6 +487,7 @@
"editStock": {
"title": "Bestand korrigieren",
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
"totalPills": "Gesamte Tabletten",
"fullBlisters": "Volle Blister",
"partialBlisterPills": "Angebrochener Blister",
"pillsPerBlister": "(je {{count}} Tabletten)",
+42 -20
View File
@@ -21,11 +21,11 @@
"badge": "Stock watch",
"noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{meds}} is running low.",
"lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
"lowWarning": "Enough stock for now, but {{meds}} is running critically low.",
"lowWarning_other": "Enough stock for now, but {{meds}} are running critically low.",
"lowWarningPrefix": "Enough stock for now, but",
"lowWarningSuffix": "is running low.",
"lowWarningSuffix_other": "are running low.",
"lowWarningSuffix": "is running critically low.",
"lowWarningSuffix_other": "are running critically low.",
"sendReminder": "🔔 Send Reminder Now"
},
"overview": {
@@ -59,10 +59,11 @@
"reminders": {
"active": "Automatic reminders active",
"status": "Status",
"allStockOk": "All stock OK",
"allOk": "All OK",
"allStockOk": "All stock good",
"allOk": "All good",
"lastReminder": "Last intake reminder",
"lastSent": "Last intake reminder",
"lastStockSent": "Last stock reminder",
"next": "Refill reminder",
"nextIn": "Refill reminder",
"inDays": "in {{days}} days",
@@ -73,8 +74,8 @@
"needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low",
"lowWarning_other": "{{count}} medications running low",
"lowWarning": "{{count}} medication running critically low",
"lowWarning_other": "{{count}} medications running critically low",
"waitingFirstCheck": "Waiting for first check",
"type": "Type",
"typeStock": "Stock",
@@ -123,7 +124,9 @@
"pillsPerBlister": "Pills per blister",
"loose": "Loose",
"total": "Total",
"stock": "Stock"
"stock": "Stock",
"totalCapacity": "Capacity",
"type": "Type"
}
},
"form": {
@@ -135,7 +138,7 @@
"takenBy": "Taken by",
"packageType": "Package Type",
"packageTypeBlister": "Blister Pack",
"packageTypeBottle": "Pill Bottle / Container",
"packageTypeBottle": "Pill Bottle",
"packs": "Packs",
"blistersPerPack": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
@@ -181,6 +184,7 @@
"calculate": "Calculate",
"calculating": "Calculating...",
"sendEmail": "📧 Send via Email",
"sendNotification": "🔔 Send Demand",
"table": {
"medication": "Medication",
"usage": "Usage",
@@ -229,25 +233,34 @@
"intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time",
"nextCheck": "Next stock check",
"lastSent": "Last sent",
"lastSent": "Last notification sent",
"lastStockSent": "Last stock reminder",
"lastIntakeSent": "Last intake reminder",
"envHint": "These values can be configured via REMINDER_HOUR and REMINDER_MINUTES_BEFORE in .env"
},
"stock": {
"title": "Stock",
"threshold": "Reminder Threshold",
"remindWhen": "Remind when supply drops below",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.",
"calculationMode": "Stock Calculation",
"automatic": "Automatic",
"automaticDesc": "Stock automatically decreases based on schedule",
"manual": "Manual",
"manualDesc": "Stock only decreases when doses are marked as taken",
"display": "Display",
"lowStockDays": "Low Stock (days)",
"lowStockTooltip": "Yellow warning color threshold",
"highStockDays": "High Stock (days)",
"highStockTooltip": "Green with star threshold"
"thresholds": "Thresholds",
"criticalStockDays": "Critical (days)",
"criticalStockTooltip": "Stock below this value is critical and needs immediate attention",
"lowStockDays": "Low (days)",
"lowStockTooltip": "Stock below this value means you should reorder soon",
"highStockDays": "High (days)",
"highStockTooltip": "Stock above this value means you are well supplied",
"thresholdValidation": "Values must be: Critical < Low < High",
"shareStockStatus": "Show Stock on Shared Links",
"shareStockStatusDesc": "Show stock status (Normal/Low/Critical) and colored borders on shared schedule links for intake users"
},
"stockReminder": {
"title": "Stock Reminder",
"description": "Sends notification when medication stock reaches",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is critical. Otherwise, only notifies once per medication until restocked."
},
"saveSettings": "Save Settings"
},
@@ -288,6 +301,7 @@
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"hasNotes": "Has notes",
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
"lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode"
},
@@ -348,6 +362,9 @@
"common": {
"loading": "Loading...",
"sending": "Sending...",
"sent": "Sent!",
"sendFailed": "Failed to send",
"networkError": "Network error",
"saving": "Saving...",
"unsavedChanges": {
"title": "Unsaved Changes",
@@ -386,6 +403,9 @@
"fullBlisters": "full blisters",
"inBlister": "in 1 blister",
"total": "total",
"pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total",
"max": "max"
},
"share": {
@@ -450,6 +470,7 @@
"refill": {
"title": "Refill",
"packs": "Packs to add",
"pillsToAdd": "Pills to add",
"loosePills": "Loose pills to add",
"pillsPerPack": "1 pack = {{count}} pills",
"addToStock": "Add to Stock",
@@ -466,6 +487,7 @@
"editStock": {
"title": "Correct Stock",
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
"totalPills": "Total pills",
"fullBlisters": "Full blisters",
"partialBlisterPills": "Partial blister",
"pillsPerBlister": "({{count}} pills each)",
+147 -37
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
@@ -80,12 +81,16 @@ function getReminderStatusData(
_lastNotificationChannel: string | null,
lastReminderMedName: string | null,
lastReminderTakenBy: string | null,
lastStockReminderSent: string | null,
_lastStockReminderChannel: string | null,
lastStockReminderMedNames: string | null,
t: (key: string, options?: Record<string, unknown>) => string,
locale: string
): {
status: { text: string; className: string };
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastSent: { date: string; medName: string | null; takenBy: string | null } | null;
lastStockSent: { date: string; medNames: string | null } | null;
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
} {
const criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter((c) => {
@@ -141,25 +146,40 @@ function getReminderStatusData(
// Convert to array and sort by days left (most urgent first)
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
// Parse last sent info
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const lastSentDate = new Date(lastAutoEmailSent);
const formattedDate = lastSentDate.toLocaleDateString(locale, {
// Parse last stock reminder sent info (from dedicated stock tracking columns)
let lastStockSent: { date: string; medNames: string | null } | null = null;
if (lastStockReminderSent) {
const sentDate = new Date(lastStockReminderSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastStockSent = {
date: formattedDate,
medNames: lastStockReminderMedNames,
};
}
lastSent = {
// Parse last intake reminder sent info (from intake tracking columns)
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) {
const sentDate = new Date(lastAutoEmailSent);
const formattedDate = sentDate.toLocaleDateString(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
lastIntakeSent = {
date: formattedDate,
medName: lastReminderMedName,
takenBy: lastReminderTakenBy,
};
}
return { status, lowStockMeds, lastSent };
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
}
export function DashboardPage() {
@@ -199,6 +219,7 @@ export function DashboardPage() {
openShareDialog,
openScheduleLightbox,
stockThresholds,
loadSettings,
} = useAppContext();
// Get structured reminder data
@@ -212,6 +233,9 @@ export function DashboardPage() {
settings.lastNotificationChannel,
settings.lastReminderMedName,
settings.lastReminderTakenBy,
settings.lastStockReminderSent,
settings.lastStockReminderChannel,
settings.lastStockReminderMedNames,
t,
getSystemLocale(i18n.language)
);
@@ -225,6 +249,50 @@ export function DashboardPage() {
(settings.shoutrrrEnabled && settings.shoutrrrIntakeReminders);
const anyRemindersEnabled = stockRemindersEnabled || intakeRemindersEnabled;
// Manual reminder send state
const [sendingReminder, setSendingReminder] = useState(false);
const [reminderResult, setReminderResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendManualReminder() {
if (!stockRemindersEnabled || reminderData.lowStockMeds.length === 0) return;
setSendingReminder(true);
setReminderResult(null);
try {
const lowStock = reminderData.lowStockMeds.map((m) => {
const cov = coverage.all.find((c) => c.name === m.name);
return {
name: m.name,
medsLeft: cov?.medsLeft ?? 0,
daysLeft: m.daysLeft,
depletionDate: cov?.depletionDate ?? null,
isCritical: m.isCritical,
};
});
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
lowStock,
}),
});
const data = await res.json();
if (res.ok) {
setReminderResult({ success: true, message: data.message || t("common.sent") });
// Refresh settings so "Last stock reminder" row appears immediately
loadSettings();
} else {
setReminderResult({ success: false, message: data.error || t("common.sendFailed") });
}
} catch {
setReminderResult({ success: false, message: t("common.networkError") });
}
setSendingReminder(false);
}
return (
<>
{anyRemindersEnabled && (
@@ -234,14 +302,11 @@ export function DashboardPage() {
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{reminderData.lowStockMeds.length === 0 && (
<span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.className === "success" && "✓ "}
{reminderData.status.text}
</span>
)}
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
</div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
{(reminderData.lowStockMeds.length > 0 ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
@@ -276,30 +341,68 @@ export function DashboardPage() {
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
// Extract first med name (medNames may be "Name (+N)")
const rawName = reminderData.lastStockSent!.medNames!;
const firstName = rawName.replace(/\s*\(\+\d+\)$/, "");
const suffix = rawName.includes("(+") ? rawName.slice(firstName.length) : "";
const medication = meds.find((m) => m.name === firstName);
return medication ? (
<>
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{firstName}
</span>
{suffix && <span className="reminder-med-name">{suffix}</span>}
</>
) : (
<span className="reminder-med-name">{rawName}</span>
);
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastIntakeSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName &&
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => m.name === reminderData.lastSent!.medName);
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
return medication ? (
<span className="med-link clickable" onClick={() => openMedDetail(medication)}>
{reminderData.lastSent!.medName}
{reminderData.lastIntakeSent!.medName}
</span>
) : (
<span className="reminder-med-name">{reminderData.lastSent!.medName}</span>
<span className="reminder-med-name">{reminderData.lastIntakeSent!.medName}</span>
);
})()}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span>
{reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastIntakeSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastSent.date}</span>
<span className="reminder-date"> {reminderData.lastIntakeSent.date}</span>
</span>
</div>
)}
</div>
)}
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={sendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderResult && (
<span className={`reminder-send-result ${reminderResult.success ? "success" : "error"}`}>
{reminderResult.message}
</span>
)}
</div>
)}
</section>
)}
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
@@ -439,9 +542,12 @@ export function DashboardPage() {
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
: formatFullBlisters(stock.fullBlisters, t)}
</span>
<span data-label={t("table.stockDetails")} className={textClass}>
<span
data-label={t("table.stockDetails")}
className={`${textClass}${med?.packageType === "bottle" ? " hide-on-card" : ""}`}
>
{med?.packageType === "bottle"
? "-"
? ""
: formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
@@ -565,7 +671,7 @@ export function DashboardPage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -597,6 +703,9 @@ export function DashboardPage() {
const med = meds.find((m) => m.name === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const status = medCov
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds)
: null;
const itemDoseIds = expandDoseIds(item.doses);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return (
@@ -620,9 +729,10 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
@@ -769,10 +879,10 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
@@ -956,10 +1066,10 @@ export function DashboardPage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && (
<span className={`status-chip small ${status.className}`}>{t(status.label)}</span>
)}
</div>
</div>
<div className="doses-col">
+106 -54
View File
@@ -340,22 +340,46 @@ export function MedicationsPage() {
</div>
<div className="med-details">
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
{t("medications.details.type")}:{" "}
<strong>
{med.packageType === "bottle" ? t("form.packageTypeBottle") : t("form.packageTypeBlister")}
</strong>
</span>
{med.packageType === "blister" ? (
<>
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
</span>
</>
) : (
<span>
{t("medications.details.totalCapacity")}: <strong>{med.totalPills ?? med.looseTablets}</strong>
</span>
)}
</div>
<div className="med-total">
{t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {t("common.pills")}
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
{(coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)) >
getPackageSize(med) && (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
)}
</div>
</div>
<div className="med-actions">
@@ -569,24 +593,38 @@ export function MedicationsPage() {
<div className="full refill-section">
<h4 className="refill-title">{t("refill.title")}</h4>
<div className="refill-form-inline">
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
{form.packageType === "blister" ? (
<>
<label>
{t("refill.packs")}
<input
type="number"
min="0"
value={refillPacks}
onChange={(e) => setRefillPacks(parseInt(e.target.value, 10) || 0)}
/>
</label>
<label>
{t("refill.loosePills")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
</>
) : (
<label>
{t("refill.pillsToAdd")}
<input
type="number"
min="0"
value={refillLoose}
onChange={(e) => setRefillLoose(parseInt(e.target.value, 10) || 0)}
/>
</label>
)}
<button
type="button"
className="success"
@@ -595,12 +633,18 @@ export function MedicationsPage() {
>
{refillSaving ? t("refill.adding") : t("refill.button")}
</button>
{(refillPacks > 0 || refillLoose > 0) && (
<span className="refill-preview">
+{refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) + refillLoose}{" "}
{t("common.pills")}
</span>
)}
{(() => {
const totalRefill =
form.packageType === "blister"
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
refillLoose
: refillLoose;
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
</span>
) : null;
})()}
</div>
</div>
)}
@@ -632,7 +676,11 @@ export function MedicationsPage() {
<div className="card-head">
<h3>{t("form.blisters.title")}</h3>
<div className="blisters-actions">
<button type="button" className="primary" onClick={() => addIntake()}>
<button
type="button"
className="primary"
onClick={() => addIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
>
+ {t("form.blisters.addIntake")}
</button>
</div>
@@ -675,25 +723,29 @@ export function MedicationsPage() {
onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
/>
</label>
<label title={t("form.blisters.takenByTooltip")}>
{t("form.blisters.takenByIntake")}
<select value={intake.takenBy} onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}>
<option value="">{t("form.blisters.takenByEveryone")}</option>
{existingPeople.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
{form.takenBy.length === 0 ? null : (
<label title={t("form.blisters.takenByTooltip")}>
{t("form.blisters.takenByIntake")}
<select value={intake.takenBy} onChange={(e) => setIntakeValue(idx, "takenBy", e.target.value)}>
{form.takenBy.map((person) => (
<option key={person} value={person}>
{person}
</option>
))}
</select>
</label>
)}
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
<span>🔔</span>
</label>
<label className="toggle-switch small">
<input
type="checkbox"
checked={intake.intakeRemindersEnabled}
onChange={(e) => setIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
{form.intakes.length > 1 && (
<button type="button" className="danger" onClick={() => removeIntake(idx)}>
+22 -14
View File
@@ -117,8 +117,11 @@ export function PlannerPage() {
}
}
async function sendPlannerEmail() {
if (!settings.notificationEmail || plannerRows.length === 0) return;
const canSendNotification =
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
async function sendPlannerNotification() {
if (!canSendNotification || plannerRows.length === 0) return;
setSendingPlannerEmail(true);
setPlannerEmailResult(null);
@@ -136,12 +139,12 @@ export function PlannerPage() {
});
const data = await res.json();
if (res.ok) {
setPlannerEmailResult({ success: true, message: data.message || "Email sent!" });
setPlannerEmailResult({ success: true, message: data.message || t("common.sent") });
} else {
setPlannerEmailResult({ success: false, message: data.error || "Failed to send" });
setPlannerEmailResult({ success: false, message: data.error || t("common.sendFailed") });
}
} catch {
setPlannerEmailResult({ success: false, message: "Network error" });
setPlannerEmailResult({ success: false, message: t("common.networkError") });
}
setSendingPlannerEmail(false);
}
@@ -210,20 +213,20 @@ export function PlannerPage() {
{row.medicationName}
</span>
<span data-label={t("planner.table.usage")}>
<strong>{row.plannerUsage}</strong>&nbsp;{t("common.pills")}
<strong>{row.plannerUsage}</strong>&nbsp;
{row.plannerUsage === 1 ? t("common.pill") : t("common.pills")}
</span>
<span data-label={t("planner.table.blisters")}>
{row.packageType === "bottle"
? `${row.plannerUsage} ${t("common.pills")}`
: `${row.blistersNeeded} × ${row.blisterSize}`}
{row.packageType === "bottle" ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
</span>
<span data-label={t("planner.table.available")}>
{row.packageType === "bottle" ? (
`${Math.round(row.loosePills * 10) / 10} ${t("common.pills")}`
`${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`
) : (
<>
{row.fullBlisters} {t("common.blisters")}
{row.loosePills > 0 && ` + ${Math.round(row.loosePills * 10) / 10} ${t("common.pills")}`}
{row.loosePills > 0 &&
` + ${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`}
</>
)}
</span>
@@ -237,10 +240,15 @@ export function PlannerPage() {
);
})}
</div>
{settings.emailEnabled && settings.notificationEmail && (
{canSendNotification && (
<div className="planner-email-action">
<button type="button" className="ghost" onClick={sendPlannerEmail} disabled={sendingPlannerEmail}>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendEmail")}
<button
type="button"
className="ghost"
onClick={sendPlannerNotification}
disabled={sendingPlannerEmail}
>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendNotification")}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
+3 -7
View File
@@ -136,7 +136,7 @@ export function SchedulePage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -186,9 +186,7 @@ export function SchedulePage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
</div>
</div>
<div className="doses-col">
@@ -285,9 +283,7 @@ export function SchedulePage() {
)}
</div>
<div className="tag-row">
<span className="tag subtle">
{item.total} {t("common.pills")} {t("common.total")}
</span>
<span className="tag subtle">{t("common.pillsTotal", { count: item.total })}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div>
</div>
+166 -60
View File
@@ -236,6 +236,75 @@ export function SettingsPage() {
)}
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stockReminder.title")}</h3>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stockReminder.description")}{" "}
<span className="status-chip small danger">{t("status.criticalStock")}</span>
</label>
<label
className={`toggle-switch small${!settings.emailEnabled && !settings.shoutrrrEnabled ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
}
onChange={(e) => {
const newVal = e.target.checked;
if (newVal) {
setSettings({
...settings,
emailStockReminders: settings.emailEnabled ? true : settings.emailStockReminders,
shoutrrrStockReminders: settings.shoutrrrEnabled ? true : settings.shoutrrrStockReminders,
});
} else {
setSettings({
...settings,
emailStockReminders: false,
shoutrrrStockReminders: false,
repeatDailyReminders: false,
});
}
}}
disabled={!settings.emailEnabled && !settings.shoutrrrEnabled}
/>
<span className="toggle-slider"></span>
</label>
</div>
<div className="setting-row compact" style={{ marginTop: "4px" }}>
<label className="setting-label">
{t("settings.stockReminder.repeatDaily")}
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stockReminder.repeatTooltip")}
>
</span>
</label>
<label
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders)) ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders)
)
}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.notifications.email")}</h3>
@@ -400,9 +469,23 @@ export function SettingsPage() {
</span>
</div>
)}
{settings.lastStockReminderSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
<span className="schedule-value">
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
{settings.lastAutoEmailSent && (
<div className="schedule-row">
<span className="schedule-label">{t("settings.schedule.lastSent")}</span>
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
<span className="schedule-value">
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
day: "2-digit",
@@ -423,51 +506,6 @@ export function SettingsPage() {
<h2>{t("settings.stock.title")}</h2>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.threshold")}</h3>
</div>
<div className="threshold-input">
<label>
<span className="threshold-label">{t("settings.stock.remindWhen")}</span>
<div className="threshold-field">
<input
type="number"
min="1"
max="90"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
<span className="threshold-unit">{t("common.days")}</span>
</div>
</label>
</div>
<div className="setting-row compact">
<label className="setting-label">
{t("settings.stock.repeatDaily")}
<span className="info-tooltip small" data-tooltip={t("settings.stock.repeatTooltip")}>
</span>
</label>
<label
className={`toggle-switch small${!((settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)) ? " disabled" : ""}`}
>
<input
type="checkbox"
checked={settings.repeatDailyReminders}
onChange={(e) => setSettings({ ...settings, repeatDailyReminders: e.target.checked })}
disabled={
!(
(settings.emailEnabled && settings.emailStockReminders && settings.notificationEmail) ||
(settings.shoutrrrEnabled && settings.shoutrrrStockReminders && settings.shoutrrrUrl)
)
}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.calculationMode")}</h3>
@@ -512,40 +550,100 @@ export function SettingsPage() {
<div className="setting-section">
<div className="section-header">
<h3>{t("settings.stock.display")}</h3>
<h3>{t("settings.stock.thresholds")}</h3>
</div>
<div className="setting-group">
<label>
<span className="field-label">{t("settings.stock.lowStockDays")}</span>
<div className="setting-group threshold-chips-group">
<label className={settings.reminderDaysBefore >= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label">
<span className="status-chip small danger">{t("status.criticalStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.criticalStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
max="364"
value={settings.reminderDaysBefore}
onChange={(e) => setSettings({ ...settings, reminderDaysBefore: Number(e.target.value) || 7 })}
/>
</div>
</label>
<label
className={
settings.lowStockDays <= settings.reminderDaysBefore ||
settings.lowStockDays >= settings.highStockDays
? "threshold-invalid"
: ""
}
>
<span className="field-label threshold-chip-label">
<span className="status-chip small warning">{t("status.lowStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.lowStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="2"
max="365"
value={settings.lowStockDays}
onChange={(e) => setSettings({ ...settings, lowStockDays: Number(e.target.value) || 30 })}
/>
<span className="info-tooltip" data-tooltip={t("settings.stock.lowStockTooltip")}>
</span>
</div>
</label>
<label>
<span className="field-label">{t("settings.stock.highStockDays")}</span>
<label className={settings.highStockDays <= settings.lowStockDays ? "threshold-invalid" : ""}>
<span className="field-label threshold-chip-label">
<span className="status-chip small high">{t("status.highStock")}</span>
<span
className="info-tooltip small tooltip-align-left"
data-tooltip={t("settings.stock.highStockTooltip")}
>
</span>
</span>
<div className="input-with-tooltip">
<input
type="number"
min="1"
min="3"
max="730"
value={settings.highStockDays}
onChange={(e) => setSettings({ ...settings, highStockDays: Number(e.target.value) || 180 })}
/>
<span className="info-tooltip" data-tooltip={t("settings.stock.highStockTooltip")}>
</span>
</div>
</label>
</div>
{(settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays) && (
<p className="threshold-validation-error">{t("settings.stock.thresholdValidation")}</p>
)}
</div>
<div className="setting-section">
<div className="setting-row compact">
<div className="setting-label">
<span>{t("settings.stock.shareStockStatus")}</span>
<span className="info-tooltip small" data-tooltip={t("settings.stock.shareStockStatusDesc")}>
</span>
</div>
<label className="toggle-switch small">
<input
type="checkbox"
checked={settings.shareStockStatus}
onChange={(e) => setSettings({ ...settings, shareStockStatus: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</div>
</div>
</article>
@@ -651,7 +749,15 @@ export function SettingsPage() {
</article>
<div className="form-footer">
<button type="submit" disabled={settingsSaving || (!settingsChanged && settingsSaved)}>
<button
type="submit"
disabled={
settingsSaving ||
(!settingsChanged && settingsSaved) ||
settings.reminderDaysBefore >= settings.lowStockDays ||
settings.lowStockDays >= settings.highStockDays
}
>
{settingsSaving
? t("common.saving")
: settingsSaved && !settingsChanged
+136 -3
View File
@@ -374,6 +374,31 @@ body.modal-open {
color: var(--danger);
}
.reminder-send-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding-left: 1.75rem;
padding-top: 0.25rem;
}
.reminder-send-row .ghost {
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
}
.reminder-send-result {
font-size: 0.8rem;
}
.reminder-send-result.success {
color: var(--success);
}
.reminder-send-result.error {
color: var(--danger);
}
.med-link {
font-weight: 600;
text-decoration: underline;
@@ -473,7 +498,6 @@ body.modal-open {
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 14px 36px var(--shadow);
overflow: hidden;
transition:
background 200ms ease,
border-color 200ms ease;
@@ -1416,6 +1440,20 @@ textarea.auto-resize {
}
.day-block.all-taken {
border-color: rgba(57, 217, 138, 0.3);
background: linear-gradient(135deg, rgba(57, 217, 138, 0.06) 0%, rgba(57, 217, 138, 0.015) 100%);
}
.day-block.all-taken .day-divider,
.day-block.all-taken.stock-warning .day-divider,
.day-block.all-taken.stock-danger .day-divider {
color: var(--success);
opacity: 0.8;
}
.day-block.past-missed {
border-color: rgba(252, 211, 77, 0.35);
}
.day-block.past-missed .day-divider {
color: var(--warning);
opacity: 0.8;
}
.day-block.today.all-taken {
border-color: var(--success);
@@ -1886,6 +1924,10 @@ textarea.auto-resize {
.status-chip.high::before {
content: "★";
}
.status-chip.small {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
}
@media (max-width: 760px) {
.table-head,
@@ -1896,6 +1938,9 @@ textarea.auto-resize {
.table-head {
display: none;
}
.table-row .hide-on-card {
display: none;
}
.table-row {
padding: 0.75rem;
display: flex;
@@ -1922,8 +1967,8 @@ textarea.auto-resize {
/* First span (name cell) - centered horizontal layout */
.table-row span:first-child {
justify-content: center;
padding-bottom: 0.5rem;
margin-bottom: 0.25rem;
padding-bottom: 0.15rem;
margin-bottom: 0;
}
.table-row span:first-child::before {
display: none; /* Hide "NAME" label on mobile */
@@ -1931,6 +1976,11 @@ textarea.auto-resize {
/* Status chip in table row - left aligned */
.table-row span.status-chip {
align-self: flex-start;
justify-content: flex-start;
gap: 0.4rem;
}
.table-row span.status-chip::before {
margin-right: 0;
}
/* Avatar + name layout - centered */
.table-row .cell-with-avatar {
@@ -2481,6 +2531,16 @@ textarea.auto-resize {
z-index: 101;
}
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
.info-tooltip.tooltip-align-left::after {
left: 0;
transform: none;
}
.info-tooltip.tooltip-align-left::before {
left: 50%;
transform: translateX(-50%);
}
.info-tooltip:hover::after,
.info-tooltip:hover::before,
.info-tooltip:focus::after,
@@ -2957,6 +3017,62 @@ textarea.auto-resize {
font-size: 0.9rem;
}
/* Threshold Chips Group - 3-column grid for Critical/Low/High */
.threshold-chips-group {
grid-template-columns: 1fr 1fr 1fr;
}
.threshold-chips-group label {
text-transform: none;
letter-spacing: normal;
}
.threshold-chip-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.threshold-chip-label .status-chip {
pointer-events: none;
}
.threshold-invalid input {
border-color: var(--danger) !important;
box-shadow: 0 0 0 1px var(--danger);
}
.threshold-validation-error {
margin: 0.75rem 0 0;
padding: 0.5rem 0.75rem;
background: rgba(255, 94, 94, 0.1);
border: 1px solid rgba(255, 94, 94, 0.3);
border-radius: 6px;
color: #fca5a5;
font-size: 0.8rem;
font-weight: 500;
}
/* Stock Reminder Trigger in Notifications */
.stock-reminder-trigger {
margin-bottom: 0.5rem;
}
.stock-reminder-trigger .setting-desc {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.stock-reminder-trigger .status-chip {
pointer-events: none;
}
/* Compact Setting Row - for inline toggles without card styling */
.setting-row.compact {
padding: 0.75rem 0;
@@ -3132,6 +3248,9 @@ textarea.auto-resize {
.setting-group {
grid-template-columns: 1fr;
}
.threshold-chips-group {
grid-template-columns: 1fr 1fr 1fr;
}
}
/* Medication Avatar */
@@ -5523,6 +5642,20 @@ a.about-version-link:hover {
justify-self: start;
}
.mobile-edit-form .blister-row .remind-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.blister-inputs .remind-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
align-self: end;
padding-bottom: 0.5rem;
}
.mobile-edit-form .blister-row .datetime-inputs {
display: flex;
gap: 0.5rem;
@@ -15,6 +15,7 @@ const mockMedication: Medication = {
id: 1,
name: "Test Med",
genericName: "Generic Name",
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
@@ -385,3 +386,197 @@ describe("MedDetailModal with refill history", () => {
}
});
});
describe("MedDetailModal intake schedule usage display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not multiply usage by personCount when intakes have per-intake takenBy", () => {
// Two people at medication level, but each intake has its own takenBy
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00" },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00" },
],
intakes: [
{ usage: 1, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false },
{ usage: 1, every: 1, start: "2024-01-01T21:00:00", takenBy: "Bob", intakeRemindersEnabled: false },
],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
// Each intake should show "1 pill" (not "2 pills")
usageElements.forEach((el) => {
expect(el.textContent).toContain("1");
expect(el.textContent).not.toMatch(/^2\b/);
});
});
it("multiplies usage by personCount for legacy blisters without per-intake takenBy", () => {
// Two people at medication level, legacy blisters without intakes
const med: Medication = {
...mockMedication,
takenBy: ["Alice", "Bob"],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
// No intakes array - legacy format
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
// Legacy: 1 pill * 2 people = "2 pills"
expect(usageElements.length).toBe(1);
expect(usageElements[0].textContent).toContain("2");
});
it("shows correct usage for single person with per-intake takenBy", () => {
const med: Medication = {
...mockMedication,
takenBy: ["Alice"],
pillWeightMg: 500,
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00" }],
intakes: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00", takenBy: "Alice", intakeRemindersEnabled: false }],
};
render(<MedDetailModal {...defaultProps} selectedMed={med} />);
const usageElements = document.querySelectorAll(".med-schedule-usage");
expect(usageElements.length).toBe(1);
// Should show "2 pills (1000 mg)" - usage=2, not multiplied
expect(usageElements[0].textContent).toContain("2");
expect(usageElements[0].textContent).toContain("1000");
});
});
describe("MedDetailModal stock overflow warning", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows warning icon when stock exceeds package capacity", () => {
const overflowCoverage: Coverage = {
name: "Test Med",
medsLeft: 49,
daysLeft: 49,
depletionDate: "2024-03-01",
depletionTime: Date.now() + 49 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
// packageSize = 1 * 1 * 30 + 0 = 30, currentStock = 49 > 30
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
expect(warningIcon?.getAttribute("data-tooltip")).toBe("tooltips.stockExceedsCapacity");
});
it("does not show warning icon when stock is within package capacity", () => {
render(<MedDetailModal {...defaultProps} />);
// packageSize = 30, currentStock = 25 < 30
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
it("does not show warning icon when stock equals package capacity", () => {
const exactCoverage: Coverage = {
name: "Test Med",
medsLeft: 30,
daysLeft: 30,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 30 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} coverage={{ all: [exactCoverage] }} />);
// packageSize = 30, currentStock = 30 — equal, no warning
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
});
describe("MedDetailModal bottle package type", () => {
const bottleMed: Medication = {
id: 2,
name: "Bottle Med",
genericName: null,
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 80,
totalPills: 100,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
updatedAt: null,
expiryDate: null,
notes: null,
};
const bottleCoverage: Coverage = {
name: "Bottle Med",
medsLeft: 80,
daysLeft: 80,
depletionDate: "2024-06-01",
depletionTime: Date.now() + 80 * 86400000,
nextDose: null,
};
const bottleProps = {
...defaultProps,
selectedMed: bottleMed,
coverage: { all: [bottleCoverage] },
};
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show blister fields in stock info section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show current stock
expect(screen.getByText(/modal\.currentStock/i)).toBeInTheDocument();
// Should NOT show full blisters or open blister labels
expect(screen.queryByText(/table\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/table\.openBlister/i)).not.toBeInTheDocument();
});
it("shows bottle type in package details section", () => {
render(<MedDetailModal {...bottleProps} />);
// Should show package type as bottle
expect(screen.getByText(/form\.packageTypeBottle/i)).toBeInTheDocument();
// Should show total capacity
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
});
it("shows pills-only refill modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showRefillModal={true} />);
// Should show pills to add label
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show packs label in refill
const refillModal = document.querySelector(".refill-modal");
// Packs label should not be present for bottle type
expect(screen.queryByText("refill.packs")).not.toBeInTheDocument();
});
it("shows total pills input in edit stock modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
// Should show total pills label
expect(screen.getByText(/editStock\.totalPills/i)).toBeInTheDocument();
// Should NOT show full blisters or partial blister labels
expect(screen.queryByText(/editStock\.fullBlisters/i)).not.toBeInTheDocument();
expect(screen.queryByText(/editStock\.partialBlisterPills/i)).not.toBeInTheDocument();
});
});
@@ -424,8 +424,14 @@ describe("MobileEditModal takenBy", () => {
render(<MobileEditModal {...defaultProps} form={form} />);
expect(screen.getByText("John")).toBeInTheDocument();
expect(screen.getByText("Jane")).toBeInTheDocument();
// Check tags are rendered (use getAllByText since names also appear in intake dropdowns)
const johnElements = screen.getAllByText("John");
const janeElements = screen.getAllByText("Jane");
expect(johnElements.length).toBeGreaterThanOrEqual(1);
expect(janeElements.length).toBeGreaterThanOrEqual(1);
// Verify the tag elements specifically exist
expect(johnElements.some((el) => el.closest(".tag"))).toBe(true);
expect(janeElements.some((el) => el.closest(".tag"))).toBe(true);
});
it("calls onRemoveTakenByPerson when tag removed", () => {
@@ -535,3 +541,52 @@ describe("MobileEditModal optional fields", () => {
expect(toggle).toBeInTheDocument();
});
});
describe("MobileEditModal bottle package type", () => {
const bottleForm: FormState = {
...defaultForm,
packageType: "bottle",
packCount: "0",
blistersPerPack: "1",
pillsPerBlister: "1",
looseTablets: "80",
totalPills: "100",
};
it("shows pills-only refill form for bottle type when editing", () => {
render(<MobileEditModal {...defaultProps} form={bottleForm} editingId={1} />);
// Should show "pillsToAdd" label for bottle
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show "packs" label in refill section
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).not.toContain("refill.packs");
expect(refillSection!.textContent).not.toContain("refill.loosePills");
});
it("shows packs and loose refill form for blister type when editing", () => {
render(<MobileEditModal {...defaultProps} form={defaultForm} editingId={1} />);
// Should show "packs" and "loosePills" labels for blister
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).toContain("refill.packs");
expect(refillSection!.textContent).toContain("refill.loosePills");
});
it("shows totalCapacity and currentPills fields for bottle form", () => {
render(<MobileEditModal {...defaultProps} form={bottleForm} />);
// Should show total capacity field
expect(screen.getByText(/form\.totalCapacity/i)).toBeInTheDocument();
// Should show current pills field
expect(screen.getByText(/form\.currentPills/i)).toBeInTheDocument();
// Should NOT show blister-specific fields
expect(screen.queryByText("form.packs")).not.toBeInTheDocument();
expect(screen.queryByText("form.blistersPerPack")).not.toBeInTheDocument();
expect(screen.queryByText("form.pillsPerBlister")).not.toBeInTheDocument();
});
});
+141 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "../../pages/DashboardPage";
@@ -160,6 +160,7 @@ const createMockAppContext = (overrides = {}) => ({
setShowClearMissedConfirm: vi.fn(),
clearingMissed: false,
dismissMissedDoses: vi.fn(),
loadSettings: vi.fn(),
...overrides,
});
@@ -592,7 +593,9 @@ describe("DashboardPage with email notifications", () => {
);
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
// The send reminder button IS shown in the reminder status bar (not the reorder card)
expect(document.querySelector(".reminder-status-bar")).toBeInTheDocument();
expect(screen.queryByText(/dashboard\.reorder\.title/i)).not.toBeInTheDocument();
});
});
@@ -622,6 +625,76 @@ describe("DashboardPage with shoutrrr notifications", () => {
const statusBar = document.querySelector(".reminder-status-bar");
expect(statusBar).toBeInTheDocument();
});
it("shows send reminder button when stock reminders are enabled and low stock exists", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
expect(screen.getByText("dashboard.reorder.sendReminder")).toBeInTheDocument();
});
it("sends manual reminder notification on button click", async () => {
global.fetch = vi.fn().mockImplementation((url: string) => {
if (url === "/api/reminder/send-email") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, message: "Notification sent via push" }),
});
}
// Settings refresh after successful send
return Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
});
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
fireEvent.click(sendButton);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/reminder/send-email",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
})
);
});
await waitFor(() => {
expect(screen.getByText("Notification sent via push")).toBeInTheDocument();
});
});
it("shows error message when manual reminder fails", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: "No notification channels configured" }),
});
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
const sendButton = screen.getByText("dashboard.reorder.sendReminder");
fireEvent.click(sendButton);
await waitFor(() => {
expect(screen.getByText("No notification channels configured")).toBeInTheDocument();
});
});
});
describe("DashboardPage with past days", () => {
@@ -819,3 +892,69 @@ describe("DashboardPage good stock state", () => {
expect(screen.getByText(/dashboard\.reorder\.allGood/i)).toBeInTheDocument();
});
});
describe("DashboardPage bottle package type", () => {
const bottleMed = {
id: 3,
name: "Ibuprofen",
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 100,
totalPills: 200,
takenBy: [],
blisters: [{ usage: 2, every: 1, start: "2024-01-01T09:00:00Z" }],
intakeRemindersEnabled: false,
notes: null,
expiryDate: null,
imageUrl: null,
updatedAt: null,
};
const bottleCoverage = {
name: "Ibuprofen",
medsLeft: 100,
daysLeft: 50,
depletionDate: "2025-04-01",
depletionTime: Date.now() + 50 * 86400000,
nextDose: null,
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockAppContext({
meds: [bottleMed],
coverage: { all: [bottleCoverage], low: [] },
coverageByMed: { Ibuprofen: bottleCoverage },
});
});
it("renders pill count instead of blisters for bottle type", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
// Should show medication name
expect(screen.getByText("Ibuprofen")).toBeInTheDocument();
// Should show pills count (bottle shows pillsCount, not blisters)
expect(screen.getByText(/table\.pillsCount/i)).toBeInTheDocument();
});
it("shows dash for stock details column for bottle type", () => {
render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>
);
// For bottle type, the stock details column shows "—"
const dashElements = document.querySelectorAll('[data-label="table.stockDetails"]');
const bottleDetails = Array.from(dashElements).find((el) => el.textContent === "—");
expect(bottleDetails).toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ const mockMeds = [
id: 1,
name: "Aspirin",
genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
@@ -25,6 +26,7 @@ const mockMeds = [
id: 2,
name: "Vitamin D",
genericName: null,
packageType: "blister" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 30,
@@ -755,9 +757,9 @@ describe("MedicationsPage intake reminders toggle", () => {
// Desktop form uses class "full blisters" container
const blistersContainer = document.querySelector(".blisters");
expect(blistersContainer).toBeInTheDocument();
// Check for the inline-checkbox that controls intake reminders in each blister row
const intakeCheckbox = document.querySelector(".blister-row .inline-checkbox");
expect(intakeCheckbox).toBeInTheDocument();
// Check for the remind-toggle-row that controls intake reminders in each blister row
const intakeToggle = document.querySelector(".blister-row .remind-toggle-row");
expect(intakeToggle).toBeInTheDocument();
});
it("can toggle intake reminders per intake", () => {
@@ -770,8 +772,8 @@ describe("MedicationsPage intake reminders toggle", () => {
</MemoryRouter>
);
// Each blister row has inline-checkbox for intake reminders
const checkbox = document.querySelector('.blister-row .inline-checkbox input[type="checkbox"]');
// Each blister row has remind-toggle-row for intake reminders
const checkbox = document.querySelector('.blister-row .remind-toggle-row input[type="checkbox"]');
if (checkbox) {
fireEvent.click(checkbox);
expect(setIntakeValue).toHaveBeenCalled();
@@ -1442,4 +1444,177 @@ describe("MedicationsPage form saved state", () => {
expect(screen.getByText(/common\.saved/i)).toBeInTheDocument();
});
it("shows stock overflow warning when medsLeft exceeds package size", () => {
const overflowMed = {
...mockMeds[0],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
};
mockContextValue = createMockContext({
meds: [overflowMed],
coverageByMed: {
[overflowMed.name]: {
name: overflowMed.name,
medsLeft: 25,
daysLeft: 25,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 25 * 86400000,
nextDose: null,
},
},
});
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// packageSize = 1*1*10 + 0 = 10, medsLeft = 25 > 10 → warning shown
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).toBeInTheDocument();
});
it("does not show stock overflow warning when stock is within capacity", () => {
const normalMed = {
...mockMeds[0],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
};
mockContextValue = createMockContext({
meds: [normalMed],
coverageByMed: {
[normalMed.name]: {
name: normalMed.name,
medsLeft: 20,
daysLeft: 20,
depletionDate: "2024-02-01",
depletionTime: Date.now() + 20 * 86400000,
nextDose: null,
},
},
});
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// packageSize = 30, medsLeft = 20 < 30 → no warning
const warningIcon = document.querySelector(".med-total .info-tooltip.tooltip-align-left.warning-text");
expect(warningIcon).not.toBeInTheDocument();
});
});
describe("MedicationsPage bottle package type", () => {
const bottleMed = {
id: 3,
name: "Ibuprofen",
genericName: null,
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
totalPills: 200,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00Z" }],
intakeRemindersEnabled: false,
notes: null,
expiryDate: null,
imageUrl: null,
updatedAt: null,
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: [bottleMed] });
mockFormHookValue = createMockFormHook();
});
it("shows bottle type and capacity instead of blister fields in med-details", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
const medDetails = document.querySelector(".med-details");
expect(medDetails).toBeInTheDocument();
// Should show type and capacity for bottle
expect(medDetails!.textContent).toContain("form.packageTypeBottle");
expect(medDetails!.textContent).toContain("medications.details.totalCapacity");
// Should NOT show blister-specific fields
expect(medDetails!.textContent).not.toContain("medications.details.blisters");
expect(medDetails!.textContent).not.toContain("medications.details.pillsPerBlister");
});
it("shows pills-only refill form for bottle type when editing", () => {
mockFormHookValue = createMockFormHook({
editingId: 3,
form: {
...createMockFormHook().form,
packageType: "bottle" as const,
totalPills: "200",
looseTablets: "150",
},
});
mockContextValue = createMockContext({ meds: [bottleMed] });
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
// Should show "pillsToAdd" label for bottle
expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument();
// Should NOT show "packs" label in refill
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).not.toContain("refill.packs");
});
});
describe("MedicationsPage blister refill shows packs", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: mockMeds });
mockFormHookValue = createMockFormHook({
editingId: 1,
form: {
...createMockFormHook().form,
packageType: "blister" as const,
packCount: "1",
blistersPerPack: "2",
pillsPerBlister: "10",
},
});
});
it("shows packs and loose pills refill fields for blister type", () => {
render(
<MemoryRouter>
<MedicationsPage />
</MemoryRouter>
);
const refillSection = document.querySelector(".refill-section");
expect(refillSection).toBeInTheDocument();
expect(refillSection!.textContent).toContain("refill.packs");
expect(refillSection!.textContent).toContain("refill.loosePills");
});
});
+99 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PlannerPage } from "../../pages/PlannerPage";
@@ -481,3 +481,101 @@ describe("PlannerPage medication detail", () => {
}
});
});
describe("PlannerPage bottle package type", () => {
const bottlePlannerRows = [
{
medicationId: 3,
medicationName: "Ibuprofen",
totalPills: 60,
plannerUsage: 20,
blisterSize: 1,
blistersNeeded: 0,
fullBlisters: 0,
loosePills: 20,
enough: true,
packageType: "bottle" as const,
},
];
const blisterPlannerRows = [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 60,
plannerUsage: 20,
blisterSize: 10,
blistersNeeded: 2,
fullBlisters: 2,
loosePills: 0,
enough: true,
packageType: "blister" as const,
},
];
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockContextValue = createMockContext({ meds: mockMeds });
});
it("shows dash for blisters column when bottle type", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(bottlePlannerRows),
});
render(
<MemoryRouter>
<PlannerPage />
</MemoryRouter>
);
// Submit the form to trigger the planner calculation
const form = document.querySelector("form.planner");
expect(form).toBeInTheDocument();
await act(async () => {
fireEvent.submit(form!);
});
// For bottle type, blisters column should show ""
await waitFor(() => {
const tableRows = document.querySelectorAll(".table-row");
expect(tableRows.length).toBeGreaterThan(0);
});
const tableRows = document.querySelectorAll(".table-row");
const bottleRow = Array.from(tableRows).find((row) => row.textContent?.includes("Ibuprofen"));
expect(bottleRow).toBeTruthy();
expect(bottleRow!.textContent).toContain("");
});
it("shows blisters calculation for blister type", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(blisterPlannerRows),
});
render(
<MemoryRouter>
<PlannerPage />
</MemoryRouter>
);
// Submit the form to trigger the planner calculation
const form = document.querySelector("form.planner");
expect(form).toBeInTheDocument();
await act(async () => {
fireEvent.submit(form!);
});
// For blister type, should show "2 × 10"
await waitFor(() => {
const tableRows = document.querySelectorAll(".table-row");
expect(tableRows.length).toBeGreaterThan(0);
});
const tableRows = document.querySelectorAll(".table-row");
const blisterRow = Array.from(tableRows).find((row) => row.textContent?.includes("Aspirin"));
expect(blisterRow).toBeTruthy();
expect(blisterRow!.textContent).toContain("2 × 10");
});
});
+291 -5
View File
@@ -30,6 +30,7 @@ const createMockContext = (overrides = {}) => ({
skipReminderIfTaken: true,
skipRemindersForTakenDoses: false,
stockCalculationMode: "automatic",
shareStockStatus: true,
stockCheckTime: "08:00",
intakeReminderTime: "09:00",
},
@@ -635,6 +636,58 @@ describe("SettingsPage stock calculation mode", () => {
});
});
describe("SettingsPage share stock status", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
shareStockStatus: true,
},
});
});
it("renders share stock status toggle", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.shareStockStatus$/)).toBeInTheDocument();
});
it("toggles share stock status setting", () => {
const setSettings = vi.fn();
mockContextValue = createMockContext({
setSettings,
settings: {
...createMockContext().settings,
shareStockStatus: true,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Find the toggle by its associated label text
const label = screen.getByText(/settings\.stock\.shareStockStatus$/);
const settingRow = label.closest(".setting-row");
const checkbox = settingRow?.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).toBeTruthy();
expect(checkbox.checked).toBe(true);
// Toggle it off
fireEvent.click(checkbox);
expect(setSettings).toHaveBeenCalledWith(expect.objectContaining({ shareStockStatus: false }));
});
});
describe("SettingsPage repeat reminders", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -898,7 +951,7 @@ describe("SettingsPage schedule overview", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.schedule\.lastSent/i)).toBeInTheDocument();
expect(screen.getByText(/settings\.schedule\.lastIntakeSent/i)).toBeInTheDocument();
});
});
@@ -964,7 +1017,8 @@ describe("SettingsPage stock display thresholds", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.lowStockDays/i)).toBeInTheDocument();
// Low stock is now shown as a chip label, not plain text
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
});
it("shows high stock days input", () => {
@@ -974,7 +1028,8 @@ describe("SettingsPage stock display thresholds", () => {
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.highStockDays/i)).toBeInTheDocument();
// High stock is now shown as a chip label, not plain text
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
});
it("allows changing high stock days", () => {
@@ -1011,14 +1066,14 @@ describe("SettingsPage repeat daily reminders", () => {
});
});
it("shows repeat daily reminders toggle", () => {
it("shows repeat daily reminders toggle in notifications", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.repeatDaily/i)).toBeInTheDocument();
expect(screen.getByText(/settings\.stockReminder\.repeatDaily/i)).toBeInTheDocument();
});
});
@@ -1154,6 +1209,237 @@ describe("SettingsPage importing state", () => {
});
});
describe("SettingsPage stock threshold chips", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
});
it("renders Critical stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Critical chip appears in both Stock Thresholds and Notification trigger
const criticalChips = screen.getAllByText(/status\.criticalStock/i);
expect(criticalChips.length).toBeGreaterThanOrEqual(1);
});
it("renders Low stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/status\.lowStock/i)).toBeInTheDocument();
});
it("renders High stock chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/status\.highStock/i)).toBeInTheDocument();
});
it("renders stock calculation mode first in stock card", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.calculationMode/i)).toBeInTheDocument();
});
it("renders thresholds section header", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholds/i)).toBeInTheDocument();
});
it("renders three threshold inputs (Critical, Low, High)", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
// Should have a threshold-chips-group with 3 labels
const chipGroup = document.querySelector(".threshold-chips-group");
expect(chipGroup).toBeInTheDocument();
const inputs = chipGroup?.querySelectorAll('input[type="number"]');
expect(inputs?.length).toBe(3);
});
});
describe("SettingsPage stock threshold validation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows validation error when Critical >= Low", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
});
it("shows validation error when Low >= High", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 200,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stock\.thresholdValidation/i)).toBeInTheDocument();
});
it("does not show validation error when thresholds are valid", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.queryByText(/settings\.stock\.thresholdValidation/i)).not.toBeInTheDocument();
});
it("disables save button when thresholds are invalid", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
settingsChanged: true,
settingsSaved: false,
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const submitBtn = document.querySelector('button[type="submit"]');
expect(submitBtn).toBeDisabled();
});
it("enables save button when thresholds are valid and changes exist", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 7,
lowStockDays: 30,
highStockDays: 180,
},
settingsChanged: true,
settingsSaved: false,
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const submitBtn = document.querySelector('button[type="submit"]');
expect(submitBtn).not.toBeDisabled();
});
it("marks invalid threshold input with error styling", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
reminderDaysBefore: 30,
lowStockDays: 30,
highStockDays: 180,
},
});
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
const invalidLabels = document.querySelectorAll(".threshold-invalid");
expect(invalidLabels.length).toBeGreaterThan(0);
});
});
describe("SettingsPage stock reminder in notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
mockContextValue = createMockContext();
});
it("renders stock reminder section in notifications card", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stockReminder\.title/i)).toBeInTheDocument();
});
it("renders stock reminder description with Critical chip", () => {
render(
<MemoryRouter>
<SettingsPage />
</MemoryRouter>
);
expect(screen.getByText(/settings\.stockReminder\.description/i)).toBeInTheDocument();
// Critical chip should appear next to the description text
const descLabel = screen.getByText(/settings\.stockReminder\.description/i);
const criticalChip = descLabel.querySelector(".status-chip.danger");
expect(criticalChip).toBeInTheDocument();
});
});
describe("SettingsPage no SMTP configured", () => {
beforeEach(() => {
vi.clearAllMocks();
+64
View File
@@ -59,6 +59,44 @@ describe("getMedTotal", () => {
expect(getMedTotal(med)).toBe(0);
});
it("calculates bottle type from looseTablets only", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
};
expect(getMedTotal(med)).toBe(150);
});
it("calculates bottle type with stock adjustment", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -10,
};
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 5,
blistersPerPack: 10,
pillsPerBlister: 20,
looseTablets: 80,
};
// Should use looseTablets only, NOT 5*10*20 + 80 = 1080
expect(getMedTotal(med)).toBe(80);
});
});
describe("getPackageSize", () => {
@@ -84,6 +122,32 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(10);
});
it("returns looseTablets for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 200,
};
expect(getPackageSize(med)).toBe(200);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
packCount: 5,
blistersPerPack: 10,
pillsPerBlister: 20,
looseTablets: 80,
stockAdjustment: 50,
};
// Should use looseTablets only, ignore stockAdjustment and blister math
expect(getPackageSize(med)).toBe(80);
});
});
describe("FIELD_LIMITS", () => {
+8
View File
@@ -181,6 +181,8 @@ export type SharedMedication = {
intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
lastStockCorrectionAt?: number | null; // Timestamp in ms for stock correction cutoff
stockAdjustment?: number; // Manual stock adjustment
};
export type SharedScheduleData = {
@@ -190,7 +192,13 @@ export type SharedScheduleData = {
medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;
normalStockDays?: number;
highStockDays?: number;
reminderDaysBefore?: number;
expiryWarningDays?: number;
};
stockCalculationMode?: "automatic" | "manual";
shareStockStatus?: boolean;
};
export type ExpiredLinkData = {