Compare commits

...

21 Commits

Author SHA1 Message Date
Daniel Volz d2bf5e61c0 chore: release v1.7.1 (#93) 2026-02-03 05:58:54 +01:00
Daniel Volz 31a89356fe fix: prevent crash when takenBy is not an array (#92)
- Add Array.isArray() checks before calling .map() on dose.takenBy
- Fixes TypeError: dose.takenBy.map is not a function
- Affects AppContext missedPastDoseIds calculation
- Affects SchedulePage dose ID generation (3 locations)

This hotfix prevents the app from crashing when dose.takenBy
is null, undefined, or any non-array value.
2026-02-03 05:57:11 +01:00
Daniel Volz 9984392b76 chore: release v1.7.0 (#90) 2026-02-01 00:23:54 +01:00
Daniel Volz 571d94bf7e feat: Add package type support and per-intake takenBy (#89)
## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
2026-01-31 23:49:11 +01:00
Daniel Volz ac4b8151e4 fix: filter out doses from previous schedules in SharedSchedule (#88)
- Add updatedAt field to share API response
- Add isDoseFromPreviousSchedule check in SharedSchedule
- Don't count doses scheduled before medication update as missed
- Syncs SharedSchedule behavior with main app's AppContext logic
2026-01-31 08:54:09 +01:00
Daniel Volz b2026637db chore: release v1.6.5 (#87) 2026-01-30 22:27:41 +01:00
Daniel Volz 99ef5bd622 feat: streamline dashboard UI and improve refill reminder (#86)
- Hide Reorder Reminder card when reminders are enabled (avoids redundancy with Reminder Bar)
- Show all low stock medications in Reminder Bar instead of just the next one
- Rename 'Reorder' to 'Refill' throughout the app
- Make medication names clickable in Refill Reminder card (opens detail modal)
- Add daysLeft display for each low stock medication
- Update translations (EN + DE)
2026-01-30 22:21:05 +01:00
Daniel Volz 1dcd333fde feat: add account deletion feature (#85)
* feat: add account deletion feature

- Add DELETE /auth/me endpoint to delete user account and all data
- Add deleteAccount() method to AuthContext
- Add Delete Account button with confirmation modal in UserProfile
- Add danger zone styling (.btn-danger, .profile-danger-zone)
- Add i18n translations for EN and DE
- Add backend tests for account deletion endpoint
- Add timeout settings to frontend vitest.config.ts
- Reduce CI timeout for frontend tests (10min -> 5min)

* fix: improve delete account section layout

- Make profile modal scrollable with max-height
- Add proper horizontal margin to danger zone
- Align delete section with form content

* fix: use ConfirmModal component for delete account dialog

- Replace inline modal with existing ConfirmModal component
- Ensures consistent button styling across all modals
- Add UI consistency rule to AGENTS.md and copilot-instructions.md

* fix: consistent styling for delete account section

- Remove warning text (users know what delete means)
- Remove border-bottom from danger zone title (section has border-top)
- Update copilot-instructions and AGENTS.md with stricter UI consistency rules
- Remove unused deleteAccountHint i18n keys

* chore: remove pre-push test hook (CI handles tests)

Tests were running twice - in pre-push hook and GitHub CI.
Removing local pre-push tests since CI provides authoritative test results.
Use 'npm test' manually before pushing if you want local feedback.
2026-01-30 21:13:11 +01:00
Daniel Volz 9ed039724e fix: use test:run script and add timeouts to badge workflow (#84)
- Add test:run script to frontend package.json (consistent with backend)
- Use npm run test:run instead of npm run test -- --run
- Add timeout-minutes to prevent infinite hangs
2026-01-30 19:30:07 +01:00
Daniel Volz 156e54f0ea fix: add CI=true to test badge workflow (#83)
Frontend tests were running in watch mode without CI=true env var,
causing the workflow to hang for 30+ minutes.
2026-01-30 19:15:54 +01:00
Daniel Volz 47e8dfe9bc fix: use date-only timestamp for stable dose IDs (#82)
- Use date-only timestamp instead of full timestamp for dose ID generation
- Ensures changing intake times doesn't invalidate past dose tracking
- IDs are now immune to time configuration changes
2026-01-30 19:12:25 +01:00
Daniel Volz aed0b20875 refactor: deduplicate formatters and improve test mocks (#81)
- Consolidate duplicate date formatting utilities
- Use shared formatters across backend and frontend
- Clean up test mocks to use consistent test data
- Remove redundant formatting functions
2026-01-30 18:37:24 +01:00
Daniel Volz fcd1b79c56 chore: add .roo/, .roomodes, and AGENTS.md to .gitignore (#80)
* chore: add .roo/ to gitignore

* chore: add .roo/, .roomodes, and AGENTS.md to .gitignore
2026-01-30 18:35:00 +01:00
Daniel Volz e725700d10 fix: only count missed doses scheduled after medication update (#79)
When medication intake times change, dose IDs change (they include
timestamps). Previously, this caused all past doses to appear as
'missed' because the old 'taken' markers no longer matched.

Now doses are only counted as 'missed' if they were scheduled AFTER
the medication's last update (updatedAt). This means:
- Legitimately missed doses still show as missed (e.g., yesterday's
  dose not taken)
- Doses from before a schedule change are NOT counted as missed
  (they were from a previous schedule configuration)

Changes:
- AppContext: Add isDoseFromPreviousSchedule helper
- SchedulePage: Use context's missedPastDoseIds instead of local calc
- Update tests to include missedPastDoseIds in mocks
2026-01-25 20:45:11 +01:00
Daniel Volz 8685e802cd fix: add frontend tests to pre-push hook (#78) 2026-01-25 20:04:03 +01:00
Daniel Volz 1793f636bf docs: update release workflow instructions (#77)
- Remove reference to release script (not used)
- Document automatic version bump via GitHub Action
- Simplify release process description
2026-01-25 19:52:10 +01:00
Daniel Volz 9cf931f243 ci: add automatic version bump on GitHub release (#76)
Creates a workflow that triggers when a release is published and
automatically updates package.json versions in backend/ and frontend/
to match the release tag version.
2026-01-25 19:49:01 +01:00
Daniel Volz 85f4d2dd21 chore: update package.json versions to 1.6.0 (#75)
The release script created tag v1.6.0 but did not update the version
numbers in package.json files. This fix ensures the About modal
displays the correct version.
2026-01-25 19:36:19 +01:00
Daniel Volz 01283ebd15 chore: rename MedAssist to MedAssist-ng in all frontend UI (#74)
Update all visible text from 'MedAssist' to 'MedAssist-ng':
- Auth page titles (login, register)
- Loading/error/initializing states
- SharedSchedule page (loading, expired, error, footer)
- AboutModal fallback text
- i18n strings for export file validation (EN/DE)
- Related test expectations
2026-01-25 19:32:17 +01:00
Daniel Volz 18bcb96869 fix: add automatic retry for auth state fetch on connection errors (#73)
When the server is restarting (e.g., during tsx watch hot reload), the
initial auth state fetch may fail. This change adds automatic retry
logic (up to 3 attempts with 1s delay) to handle transient connection
errors gracefully instead of immediately showing the error screen.
2026-01-25 19:16:24 +01:00
Daniel Volz d516bdea7d fix: add credentials to all fetch calls for auth cookie support (#72)
* fix: add credentials to all fetch calls for auth cookie support

- Add credentials: include to useMedications.ts fetch calls
- Add credentials: include to MedicationsPage.tsx save function
- Add credentials: include to useSettings.ts settings update
- Add credentials: include to useShare.ts share generation
- Add credentials: include to DashboardPage.tsx reminder email
- Add credentials: include to PlannerPage.tsx usage calculation
- Make create-release workflow skip if release already exists

* fix: default to ntfy-style notifications for HTTP URLs

- Change notification logic to use plain text format by default
- Only use JSON format for known webhook services (Discord, Slack, Telegram, Gotify)
- This fixes ntfy URLs not being recognized when hostname doesn't contain 'ntfy'

* feat: highlight medication being edited

- Add blue border and background to the medication row being edited
- Show medication avatar and name in the edit form header
- Makes it easy to identify which medication is being edited when there are many

* fix: use proper URL parsing for webhook detection (CodeQL security fix)

Replace vulnerable .includes() URL checks with proper URL hostname
parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com).

Fixes CodeQL alerts #33 and #34 (js/incomplete-url-substring-sanitization)
2026-01-25 19:10:41 +01:00
63 changed files with 3574 additions and 2752 deletions
+7 -11
View File
@@ -195,19 +195,14 @@ gh pr merge --squash --delete-branch
### Release Workflow (MANDATORY for minor/major releases) ### Release Workflow (MANDATORY for minor/major releases)
The `main` branch is protected - releases must go through the automated release script. The `main` branch is protected - releases are created via GitHub's release UI or API.
**Release Process:** **Release Process:**
```bash 1. Create a new release on GitHub with tag `vX.Y.Z`
# 1. Run release script (creates PR, waits for CI, merges, creates tag) 2. **Automatic Version Bump**: A GitHub Action (`version-bump.yml`) automatically updates `package.json` versions to match the release tag
./scripts/release.sh [patch|minor|major] 3. User asks AI to write release notes: "Write the release notes for vX.Y.Z"
4. AI writes descriptive release notes following the style guide below
# 2. GitHub Actions creates a DRAFT release automatically 5. User publishes the release with the written notes
# 3. User asks AI to write release notes:
# "Write the release notes for vX.Y.Z"
# 4. AI writes descriptive release notes following the style guide below
# 5. User publishes the draft release with the written notes
```
> ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes! > ⚠️ **MANDATORY for minor and major releases**: The AI assistant MUST write proper descriptive release notes!
> Do NOT just publish the auto-generated commit list. Follow the process above. > Do NOT just publish the auto-generated commit list. Follow the process above.
@@ -505,6 +500,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **API responses**: Return objects directly, Fastify serializes to JSON - **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars - **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json` - **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
- **UI Consistency**: Always use existing components for modals, buttons, and forms. For confirmation dialogs, use `ConfirmModal` component. Never create inline modals with custom button styling - all UI elements must match the existing design system. When adding new sections to existing components, ensure font sizes, spacing, margins, and button styles match exactly with other sections. Check existing CSS classes before creating new ones.
## Database Schema Changes (IMPORTANT: Backward Compatibility!) ## Database Schema Changes (IMPORTANT: Backward Compatibility!)
+16
View File
@@ -137,13 +137,28 @@ jobs:
with: with:
fetch-depth: 0 # Fetch all history for changelog generation fetch-depth: 0 # Fetch all history for changelog generation
- name: Check if release exists
id: check_release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
if gh release view "$CURRENT_TAG" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $CURRENT_TAG already exists, skipping creation"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get previous tag - name: Get previous tag
if: steps.check_release.outputs.exists == 'false'
id: prev_tag id: prev_tag
run: | run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog - name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog id: changelog
run: | run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/} CURRENT_TAG=${GITHUB_REF#refs/tags/}
@@ -172,6 +187,7 @@ jobs:
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md
- name: Create GitHub Release - name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
body_path: changelog.md body_path: changelog.md
+7 -1
View File
@@ -41,6 +41,9 @@ jobs:
- name: Run backend tests and capture count - name: Run backend tests and capture count
id: backend-tests id: backend-tests
working-directory: backend working-directory: backend
timeout-minutes: 5
env:
CI: true
run: | run: |
OUTPUT=$(npm run test:run 2>&1) || true OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT" echo "$OUTPUT"
@@ -51,8 +54,11 @@ jobs:
- name: Run frontend tests and capture count - name: Run frontend tests and capture count
id: frontend-tests id: frontend-tests
working-directory: frontend working-directory: frontend
timeout-minutes: 5
env:
CI: true
run: | run: |
OUTPUT=$(npm run test -- --run 2>&1) || true OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT" echo "$OUTPUT"
# Extract "Tests X passed" from output # Extract "Tests X passed" from output
PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1) PASSED=$(echo "$OUTPUT" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
+57
View File
@@ -0,0 +1,57 @@
name: Version Bump on Release
on:
release:
types: [published]
permissions:
contents: write
jobs:
version-bump:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: |
# Extract version from tag (e.g., v1.6.0 -> 1.6.0)
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Update package.json versions
run: |
VERSION="${{ steps.version.outputs.version }}"
# Update backend/package.json
jq --arg v "$VERSION" '.version = $v' backend/package.json > backend/package.json.tmp
mv backend/package.json.tmp backend/package.json
# Update frontend/package.json
jq --arg v "$VERSION" '.version = $v' frontend/package.json > frontend/package.json.tmp
mv frontend/package.json.tmp frontend/package.json
echo "Updated versions to $VERSION"
cat backend/package.json | head -5
cat frontend/package.json | head -5
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
# Only commit if there are changes
if git diff --staged --quiet; then
echo "No version changes needed"
else
git commit -m "chore: bump version to ${{ steps.version.outputs.version }} [skip ci]"
git push origin main
fi
+3
View File
@@ -71,4 +71,7 @@ Thumbs.db
*.local *.local
.cache/ .cache/
.turbo/ .turbo/
.roo/
.roomodes
AGENTS.md
docs/TECH_STACK.md docs/TECH_STACK.md
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
echo "Running backend tests before push..."
cd backend && CI=true npm test
if [ $? -ne 0 ]; then
echo "❌ Backend tests failed. Push aborted."
echo "Use 'git push --no-verify' to skip tests if needed."
exit 1
fi
echo "✅ Backend tests passed!"
@@ -0,0 +1,3 @@
-- Add package type support (blister vs bottle)
ALTER TABLE medications ADD COLUMN package_type TEXT DEFAULT 'blister' NOT NULL;
ALTER TABLE medications ADD COLUMN total_pills INTEGER;
@@ -0,0 +1,3 @@
-- Add dose_unit column and intakes JSON array for per-intake takenBy support
ALTER TABLE `medications` ADD `dose_unit` text(20) DEFAULT 'mg';--> statement-breakpoint
ALTER TABLE `medications` ADD `intakes_json` text DEFAULT '[]' NOT NULL;
+886
View File
@@ -0,0 +1,886 @@
{
"version": "6",
"dialect": "sqlite",
"id": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0",
"prevId": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
"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
},
"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
@@ -29,6 +29,20 @@
"when": 1769354512857, "when": 1769354512857,
"tag": "0003_add_reminder_info_columns", "tag": "0003_add_reminder_info_columns",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769886564000,
"tag": "0004_add_package_type",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1769893708813,
"tag": "0005_add_intakes_json",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"@fastify/cookie": "^10.0.1", "@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.5.0", "version": "1.7.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+7
View File
@@ -92,6 +92,13 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
// Added for more detailed reminder info display // Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`, `ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`, `ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
// Added for package type support (blister vs bottle)
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
`ALTER TABLE medications ADD COLUMN total_pills integer`,
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
`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 '[]'`,
]; ];
for (const sql of alterMigrations) { for (const sql of alterMigrations) {
+9 -4
View File
@@ -28,16 +28,21 @@ export const medications = sqliteTable("medications", {
name: text("name", { length: 100 }).notNull(), name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }), genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
packCount: integer("pack_count").notNull().default(1), packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1), blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1), pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered) totalPills: integer("total_pills"), // For bottle type: total capacity of the container
looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
pillWeightMg: integer("pill_weight_mg"), pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"), doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
everyJson: text("every_json").notNull().default("[]"), usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
startJson: text("start_json").notNull().default("[]"), everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
// New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}]
intakesJson: text("intakes_json").notNull().default("[]"),
imageUrl: text("image_url"), imageUrl: text("image_url"),
expiryDate: text("expiry_date"), expiryDate: text("expiry_date"),
notes: text("notes"), notes: text("notes"),
+40
View File
@@ -534,4 +534,44 @@ export async function authRoutes(app: FastifyInstance) {
return { ok: true }; return { ok: true };
} }
); );
// ---------------------------------------------------------------------------
// DELETE /auth/me - Delete user account and all data
// ---------------------------------------------------------------------------
app.delete(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
// Clear auth cookies
return reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true, message: "Account deleted" });
}
);
} }
+36 -34
View File
@@ -9,6 +9,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images"); const IMAGES_DIR = resolve(process.cwd(), "data/images");
@@ -26,6 +27,7 @@ const scheduleSchema = z.object({
every: z.number().int().min(1), every: z.number().int().min(1),
start: z.string(), // ISO datetime string start: z.string(), // ISO datetime string
remind: z.boolean().optional().default(false), remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
}); });
const inventorySchema = z.object({ const inventorySchema = z.object({
@@ -43,6 +45,7 @@ const medicationExportSchema = z.object({
takenBy: z.array(z.string()).default([]), takenBy: z.array(z.string()).default([]),
inventory: inventorySchema, inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(), pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]), schedules: z.array(scheduleSchema).default([]),
expiryDate: z.string().nullable().optional(), expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
@@ -125,39 +128,24 @@ async function getUserId(request: any, reply: any): Promise<number> {
return authUser.id; return authUser.id;
} }
// Parse takenByJson safely // Parse intakes from DB format to export format (with per-intake takenBy)
function parseTakenByJson(takenByJson: string | null | undefined): string[] { function parseIntakesForExport(
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// Parse blisters from DB format to export format
function parseBlistersForExport(
row: typeof medications.$inferSelect row: typeof medications.$inferSelect
): Array<{ usage: number; every: number; start: string; remind: boolean }> { ): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
try { // Use the new parseIntakesJson which falls back to legacy format
const usage = JSON.parse(row.usageJson || "[]") as number[]; const intakes = parseIntakesJson(
const every = JSON.parse(row.everyJson || "[]") as number[]; row.intakesJson,
const start = JSON.parse(row.startJson || "[]") as string[]; { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
const len = Math.min(usage.length, every.length, start.length); row.intakeRemindersEnabled ?? false
const schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = []; );
for (let i = 0; i < len; i++) {
schedules.push({ return intakes.map((intake) => ({
usage: usage[i], usage: intake.usage,
every: every[i], every: intake.every,
start: start[i], start: intake.start,
remind: row.intakeRemindersEnabled ?? false, remind: intake.intakeRemindersEnabled,
}); takenBy: intake.takenBy, // Per-intake takenBy
} }));
return schedules;
} catch {
return [];
}
} }
// Read image file and convert to base64 data URL // Read image file and convert to base64 data URL
@@ -289,7 +277,8 @@ export async function exportRoutes(app: FastifyInstance) {
stockAdjustment: med.stockAdjustment ?? 0, stockAdjustment: med.stockAdjustment ?? 0,
}, },
pillWeightMg: med.pillWeightMg, pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med), doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
expiryDate: med.expiryDate, expiryDate: med.expiryDate,
notes: med.notes, notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -473,12 +462,23 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>(); const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) { for (const med of importData.medications) {
// Convert schedules back to JSON arrays // Convert schedules to both legacy and new formats
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage)); const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every)); const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start)); const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const takenByJson = JSON.stringify(med.takenBy); const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy)
const intakesJson = JSON.stringify(
med.schedules.map((s) => ({
usage: s.usage,
every: s.every,
start: s.start,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
// Check if any schedule has remind enabled // Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled; const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
@@ -496,6 +496,8 @@ export async function exportRoutes(app: FastifyInstance) {
stockAdjustment: med.inventory.stockAdjustment ?? 0, stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null, lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null, pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
intakesJson,
usageJson, usageJson,
everyJson, everyJson,
startJson, startJson,
+164 -76
View File
@@ -9,60 +9,50 @@ import { doseTracking, medications } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { parseLocalDateTime } from "../utils/scheduler-utils.js"; import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images"); const IMAGES_DIR = resolve(process.cwd(), "data/images");
// New intake schema with per-intake takenBy
const intakeSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime({ local: true }),
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
});
// Legacy blister schema (for backward compatibility during transition)
const blisterSchema = z.object({ const blisterSchema = z.object({
usage: z.number().nonnegative(), usage: z.number().nonnegative(),
every: z.number().int().min(1), every: z.number().int().min(1),
start: z.string().datetime({ local: true }), start: z.string().datetime({ local: true }),
}); });
const medicationSchema = z.object({ const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
name: z.string().trim().min(1).max(100), const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().int().min(1).nullable().optional(),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
blisters: z.array(blisterSchema).min(1).max(12),
});
function zipBlisters(usage: number[], every: number[], start: string[]) { const medicationSchema = z
const len = Math.min(usage.length, every.length, start.length); .object({
const blisters: Array<{ usage: number; every: number; start: string }> = []; name: z.string().trim().min(1).max(100),
for (let i = 0; i < len; i++) { genericName: z.string().trim().max(100).nullable().optional(),
blisters.push({ usage: usage[i], every: every[i], start: start[i] }); takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
} packageType: packageTypeSchema,
return blisters; packCount: z.number().int().min(0).default(1),
} blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
function parseBlisters(row: typeof medications.$inferSelect) { totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
try { looseTablets: z.number().int().min(0).default(0),
const usage = JSON.parse(row.usageJson) as number[]; pillWeightMg: z.number().nonnegative().nullable().optional(),
const every = JSON.parse(row.everyJson) as number[]; doseUnit: doseUnitSchema,
const start = JSON.parse(row.startJson) as string[]; expiryDate: z.string().nullable().optional(),
return zipBlisters(usage, every, start); notes: z.string().max(2000).nullable().optional(),
} catch (_err) { intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat)
return []; // Accept either new intakes format or legacy blisters format
} intakes: z.array(intakeSchema).min(1).max(12).optional(),
} blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
})
function parseTakenByJson(takenByJson: string | null | undefined): string[] { .refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" });
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
export async function medicationRoutes(app: FastifyInstance) { export async function medicationRoutes(app: FastifyInstance) {
// All medication routes require auth // All medication routes require auth
@@ -88,26 +78,40 @@ export async function medicationRoutes(app: FastifyInstance) {
app.get("/medications", async (request, reply) => { app.get("/medications", async (request, reply) => {
const userId = await getUserId(request, reply); const userId = await getUserId(request, reply);
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
return rows.map((row) => ({ return rows.map((row) => {
id: row.id, // Parse intakes from new format, falling back to legacy
name: row.name, const intakes = parseIntakesJson(
genericName: row.genericName, row.intakesJson,
takenBy: parseTakenByJson(row.takenByJson), { usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
packCount: row.packCount ?? 1, row.intakeRemindersEnabled ?? false
blistersPerPack: row.blistersPerPack ?? 1, );
pillsPerBlister: row.pillsPerBlister ?? 1,
looseTablets: row.looseTablets ?? 0, return {
stockAdjustment: row.stockAdjustment ?? 0, id: row.id,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, name: row.name,
pillWeightMg: row.pillWeightMg, genericName: row.genericName,
blisters: parseBlisters(row), takenBy: parseTakenByJson(row.takenByJson),
imageUrl: row.imageUrl, packageType: row.packageType ?? "blister",
expiryDate: row.expiryDate, packCount: row.packCount ?? 1,
notes: row.notes, blistersPerPack: row.blistersPerPack ?? 1,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false, pillsPerBlister: row.pillsPerBlister ?? 1,
dismissedUntil: row.dismissedUntil ?? null, totalPills: row.totalPills ?? null,
updatedAt: row.updatedAt, looseTablets: row.looseTablets ?? 0,
})); stockAdjustment: row.stockAdjustment ?? 0,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
doseUnit: row.doseUnit ?? "mg",
intakes, // New unified format with per-intake takenBy
// Legacy blisters format (for backward compat with frontend during transition)
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
dismissedUntil: row.dismissedUntil ?? null,
updatedAt: row.updatedAt,
};
});
}); });
app.post("/medications", async (req, reply) => { app.post("/medications", async (req, reply) => {
@@ -119,19 +123,50 @@ export async function medicationRoutes(app: FastifyInstance) {
name, name,
genericName, genericName,
takenBy, takenBy,
packageType,
packCount, packCount,
blistersPerPack, blistersPerPack,
pillsPerBlister, pillsPerBlister,
totalPills,
looseTablets, looseTablets,
pillWeightMg, pillWeightMg,
doseUnit,
expiryDate, expiryDate,
notes, notes,
intakeRemindersEnabled, intakeRemindersEnabled,
blisters, intakes: inputIntakes,
blisters: inputBlisters,
} = parsed.data; } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every)); // Convert to unified intakes format
const startJson = JSON.stringify(blisters.map((s) => s.start)); let intakes: Intake[];
if (inputIntakes) {
// New format with per-intake takenBy
intakes = inputIntakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
// Store both formats for backward compatibility
const intakesJson = JSON.stringify(intakes);
const usageJson = JSON.stringify(intakes.map((s) => s.usage));
const everyJson = JSON.stringify(intakes.map((s) => s.every));
const startJson = JSON.stringify(intakes.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []); const takenByJson = JSON.stringify(takenBy || []);
const [inserted] = await db const [inserted] = await db
@@ -141,14 +176,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name, name,
genericName: genericName || null, genericName: genericName || null,
takenByJson, takenByJson,
packageType: packageType ?? "blister",
packCount, packCount,
blistersPerPack, blistersPerPack,
pillsPerBlister, pillsPerBlister,
totalPills: totalPills || null,
looseTablets, looseTablets,
pillWeightMg: pillWeightMg || null, pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null, expiryDate: expiryDate || null,
notes: notes || null, notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson, usageJson,
everyJson, everyJson,
startJson, startJson,
@@ -160,14 +199,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name: inserted.name, name: inserted.name,
genericName: inserted.genericName, genericName: inserted.genericName,
takenBy: parseTakenByJson(inserted.takenByJson), takenBy: parseTakenByJson(inserted.takenByJson),
packageType: inserted.packageType ?? "blister",
packCount: inserted.packCount, packCount: inserted.packCount,
blistersPerPack: inserted.blistersPerPack, blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister, pillsPerBlister: inserted.pillsPerBlister,
totalPills: inserted.totalPills ?? null,
looseTablets: inserted.looseTablets, looseTablets: inserted.looseTablets,
stockAdjustment: inserted.stockAdjustment ?? 0, stockAdjustment: inserted.stockAdjustment ?? 0,
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: inserted.pillWeightMg, pillWeightMg: inserted.pillWeightMg,
blisters, doseUnit: inserted.doseUnit ?? "mg",
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: inserted.imageUrl, imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate, expiryDate: inserted.expiryDate,
notes: inserted.notes, notes: inserted.notes,
@@ -195,19 +238,50 @@ export async function medicationRoutes(app: FastifyInstance) {
name, name,
genericName, genericName,
takenBy, takenBy,
packageType,
packCount, packCount,
blistersPerPack, blistersPerPack,
pillsPerBlister, pillsPerBlister,
totalPills,
looseTablets, looseTablets,
pillWeightMg, pillWeightMg,
doseUnit,
expiryDate, expiryDate,
notes, notes,
intakeRemindersEnabled, intakeRemindersEnabled,
blisters, intakes: inputIntakes,
blisters: inputBlisters,
} = parsed.data; } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every)); // Convert to unified intakes format
const startJson = JSON.stringify(blisters.map((s) => s.start)); let intakes: Intake[];
if (inputIntakes) {
// New format with per-intake takenBy
intakes = inputIntakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
// Store both formats for backward compatibility
const intakesJson = JSON.stringify(intakes);
const usageJson = JSON.stringify(intakes.map((s) => s.usage));
const everyJson = JSON.stringify(intakes.map((s) => s.every));
const startJson = JSON.stringify(intakes.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []); const takenByJson = JSON.stringify(takenBy || []);
const result = await db const result = await db
@@ -216,14 +290,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name, name,
genericName: genericName || null, genericName: genericName || null,
takenByJson, takenByJson,
packageType: packageType ?? "blister",
packCount, packCount,
blistersPerPack, blistersPerPack,
pillsPerBlister, pillsPerBlister,
totalPills: totalPills || null,
looseTablets, looseTablets,
pillWeightMg: pillWeightMg || null, pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null, expiryDate: expiryDate || null,
notes: notes || null, notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false, intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson, usageJson,
everyJson, everyJson,
startJson, startJson,
@@ -236,7 +314,7 @@ export async function medicationRoutes(app: FastifyInstance) {
// Clean up dose tracking entries that are before the earliest start date // Clean up dose tracking entries that are before the earliest start date
// This ensures consistency when the user changes the start date // This ensures consistency when the user changes the start date
const earliestStart = Math.min(...blisters.map((b) => parseLocalDateTime(b.start).getTime())); const earliestStart = Math.min(...intakes.map((b) => parseLocalDateTime(b.start).getTime()));
if (!Number.isNaN(earliestStart)) { if (!Number.isNaN(earliestStart)) {
// Get all dose tracking entries for this medication and filter out invalid ones // Get all dose tracking entries for this medication and filter out invalid ones
const allDoses = await db const allDoses = await db
@@ -265,14 +343,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name: result[0].name, name: result[0].name,
genericName: result[0].genericName, genericName: result[0].genericName,
takenBy: parseTakenByJson(result[0].takenByJson), takenBy: parseTakenByJson(result[0].takenByJson),
packageType: result[0].packageType ?? "blister",
packCount: result[0].packCount, packCount: result[0].packCount,
blistersPerPack: result[0].blistersPerPack, blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister, pillsPerBlister: result[0].pillsPerBlister,
totalPills: result[0].totalPills ?? null,
looseTablets: result[0].looseTablets, looseTablets: result[0].looseTablets,
stockAdjustment: result[0].stockAdjustment ?? 0, stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: result[0].pillWeightMg, pillWeightMg: result[0].pillWeightMg,
blisters, doseUnit: result[0].doseUnit ?? "mg",
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: result[0].imageUrl, imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate, expiryDate: result[0].expiryDate,
notes: result[0].notes, notes: result[0].notes,
@@ -428,7 +510,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const now = new Date(); const now = new Date();
const payload = rows.map((row) => { const payload = rows.map((row) => {
const blisters = parseBlisters(row); // Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const usageTotal = calculateUsageInRange(blisters, start, end); const usageTotal = calculateUsageInRange(blisters, start, end);
const pillsPerBlister = row.pillsPerBlister ?? 1; const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 1; const packCount = row.packCount ?? 1;
+26 -3
View File
@@ -482,10 +482,33 @@ export async function sendShoutrrrNotification(
) )
.trim(); .trim();
// Determine notification type based on validation result and URL pattern // Determine notification type based on URL hostname
const isNtfyUrl = isNtfy || sanitizedUrl.includes("ntfy.sh") || sanitizedUrl.includes("/ntfy/"); // Use JSON format only for known webhook services that require it
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
let isJsonWebhook = false;
try {
const parsedUrl = new URL(sanitizedUrl);
const hostname = parsedUrl.hostname.toLowerCase();
const pathname = parsedUrl.pathname.toLowerCase();
if (isNtfyUrl) { isJsonWebhook =
// Discord webhooks
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
// Slack webhooks
hostname === "hooks.slack.com" ||
hostname.endsWith(".hooks.slack.com") ||
// Telegram API
hostname === "api.telegram.org" ||
// Gotify (can be self-hosted, so check if "gotify" is in hostname)
hostname.includes("gotify");
} catch {
// If URL parsing fails, default to ntfy-style
isJsonWebhook = false;
}
// Default to ntfy-style (plain text with Title header) for all other HTTP URLs
// This works for ntfy, Apprise, and most simple push services
if (!isJsonWebhook) {
targetUrl = sanitizedUrl; targetUrl = sanitizedUrl;
headers = { Title: cleanTitle, Tags: "pill" }; headers = { Title: cleanTitle, Tags: "pill" };
body = message; body = message;
+54 -33
View File
@@ -7,6 +7,12 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds // Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000; const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -35,17 +41,6 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
return authUser.id; return authUser.id;
} }
// Helper to parse takenByJson
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
try {
const parsed = JSON.parse(takenByJson);
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
} catch {
return [];
}
}
// ============================================================================= // =============================================================================
// Share Routes // Share Routes
// ============================================================================= // =============================================================================
@@ -88,27 +83,32 @@ export async function shareRoutes(app: FastifyInstance) {
// Use SQLite JSON function to check if takenBy is in the array // Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId)); const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Filter medications where takenByJson array contains the share.takenBy value // Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => { const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(share.takenBy); const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
}); });
// Parse blisters and build schedule data // Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => { const medicationsWithBlisters = meds.map((med) => {
let blisters: { usage: number; every: number; start: string }[] = []; // Parse intakes from new format, falling back to legacy
try { const intakes = parseIntakesJson(
const usageArr = JSON.parse(med.usageJson || "[]"); med.intakesJson,
const everyArr = JSON.parse(med.everyJson || "[]"); { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
const startArr = JSON.parse(med.startJson || "[]"); med.intakeRemindersEnabled ?? false
blisters = usageArr.map((usage: number, i: number) => ({ );
usage,
every: everyArr[i] ?? 1, // Convert to legacy blisters format for backward compat
start: startArr[i] ?? new Date().toISOString(), const blisters = intakes.map((i) => ({
})); usage: i.usage,
} catch { every: i.every,
blisters = []; start: i.start,
} }));
// Parse takenBy JSON array // Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
@@ -120,6 +120,7 @@ export async function shareRoutes(app: FastifyInstance) {
name: med.name, name: med.name,
genericName: med.genericName, genericName: med.genericName,
pillWeightMg: med.pillWeightMg, pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl, imageUrl: med.imageUrl,
totalPills, totalPills,
packCount: med.packCount, packCount: med.packCount,
@@ -127,8 +128,10 @@ export async function shareRoutes(app: FastifyInstance) {
looseTablets: med.looseTablets, looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister, pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray, takenBy: takenByArray,
blisters, intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil, dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
}; };
}); });
@@ -162,11 +165,16 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data; const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in JSON array) // Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId)); const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const medsForPerson = allMeds.filter((med) => { const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(takenBy); const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(takenBy, takenByArray, intakes);
}); });
if (medsForPerson.length === 0) { if (medsForPerson.length === 0) {
@@ -205,17 +213,30 @@ export async function shareRoutes(app: FastifyInstance) {
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => { app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply); const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from JSON arrays) // Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db const meds = await db
.select({ takenByJson: medications.takenByJson }) .select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications) .from(medications)
.where(eq(medications.userId, userId)); .where(eq(medications.userId, userId));
// Collect all unique person names from all takenByJson arrays // Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>(); const allPeople = new Set<string>();
for (const med of meds) { for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson); const takenByArray = parseTakenByJson(med.takenByJson);
for (const person of takenByArray) { const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person); if (person) allPeople.add(person);
} }
} }
@@ -8,15 +8,15 @@ import { getDateLocale, getTranslations, type Language, t } from "../i18n/transl
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
// Import shared utilities // Import shared utilities
import { import {
type Blister,
cleanOldIntakeReminders, cleanOldIntakeReminders,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
getTimezone, getTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type Intake,
type IntakeReminderState, type IntakeReminderState,
parseBlisters,
parseIntakeReminderState, parseIntakeReminderState,
parseIntakesJson,
parseTakenByJson, parseTakenByJson,
type UpcomingIntake, type UpcomingIntake,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
@@ -42,10 +42,6 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
} }
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
return parseBlisters(row);
}
async function sendIntakeReminderEmail( async function sendIntakeReminderEmail(
email: string, email: string,
intakes: UpcomingIntake[], intakes: UpcomingIntake[],
@@ -79,11 +75,10 @@ async function sendIntakeReminderEmail(
return pillText; return pillText;
}; };
// Helper to format medication name with takenBy (array of names) // Helper to format medication name with takenBy (single person or null)
const formatMedName = (intake: UpcomingIntake): string => { const formatMedName = (intake: UpcomingIntake): string => {
if (intake.takenBy.length > 0) { if (intake.takenBy) {
const namesStr = intake.takenBy.join(", "); return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}</span>`;
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: namesStr })}</span>`;
} }
return intake.medName; return intake.medName;
}; };
@@ -176,7 +171,7 @@ ${description}
${intakes ${intakes
.map((i) => { .map((i) => {
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : ""; const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`; return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
}) })
.join("\n")} .join("\n")}
@@ -295,62 +290,92 @@ async function checkAndSendIntakeRemindersForUser(
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders // Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) { for (const med of medsWithReminders) {
const blisters = parseBlistersFromRow(med); // Parse intakes using new format (with per-intake takenBy), falling back to legacy
const takenByArray = parseTakenByJson(med.takenByJson); const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
logger.info( logger.info(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters` `[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
); );
// Process each blister separately to track blisterIndex // Filter intakes that have reminders enabled (per-intake setting or medication-level)
blisters.forEach((blister, blisterIndex) => { 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`);
}
return hasReminder;
});
// 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.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}` `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
); );
// Always get upcoming intakes (15 min before) for first reminders // Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes( const upcomingIntakes = getUpcomingIntakes(
med.name, med.name,
[blister], [intake],
REMINDER_MINUTES_BEFORE, REMINDER_MINUTES_BEFORE,
takenByArray, medicationTakenBy,
med.pillWeightMg, med.pillWeightMg,
locale, locale,
tz tz,
undefined, // nowOverride
med.id,
med.doseUnit ?? "mg"
); );
logger.info( logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)` `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
); );
// Add upcoming intakes for first reminders // Add upcoming intakes for first reminders
allUpcoming.push( allUpcoming.push(
...upcomingIntakes.map((intake) => ({ ...upcomingIntakes.map((upcomingIntake) => ({
...intake, ...upcomingIntake,
medicationId: med.id, medicationId: med.id,
blisterIndex, blisterIndex: actualIndex,
})) }))
); );
// If repeat reminders enabled, also check for missed intakes (past the intake time) // If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) { if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz); const allTodaysIntakes = getTodaysIntakes(
logger.info( med.name,
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}` [intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
); );
const missedIntakes = allTodaysIntakes.filter((intake) => intake.intakeTime.getTime() < now.getTime());
logger.info( logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)` `[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(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
); );
// Add missed intakes for repeat reminders (only if not already in upcoming list) // Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime())); const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
allUpcoming.push( allUpcoming.push(
...missedIntakes ...missedIntakes
.filter((intake) => !upcomingTimes.has(intake.intakeTime.getTime())) .filter((missed) => !upcomingTimes.has(missed.intakeTime.getTime()))
.map((intake) => ({ .map((missed) => ({
...intake, ...missed,
medicationId: med.id, medicationId: med.id,
blisterIndex, blisterIndex: actualIndex,
})) }))
); );
} }
@@ -442,20 +467,31 @@ async function checkAndSendIntakeRemindersForUser(
// Filter out reminders for doses that were already taken // Filter out reminders for doses that were already taken
remindersToSend = remindersToSend.filter((intake) => { remindersToSend = remindersToSend.filter((intake) => {
const timestamp = intake.intakeTime.getTime(); // Convert to date-only timestamp (midnight) to match frontend dose ID format
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
// Check both with and without person suffix // Check both with and without person suffix
if (intake.takenBy.length > 0) { if (intake.takenBy) {
// For multi-person medications, check if any person has taken it // For person-specific intake, check if that person has taken it
const anyTaken = intake.takenBy.some((person) => { const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`; const isTaken = takenDoseIds.has(doseId);
return takenDoseIds.has(doseId); if (isTaken) {
}); logger.info(
return !anyTaken; // Skip if any person has taken it `[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
} else { } else {
// For non-person-specific medications // For non-person-specific intakes
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`; const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
return !takenDoseIds.has(doseId); const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.info(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
} }
}); });
@@ -545,8 +581,7 @@ async function checkAndSendIntakeRemindersForUser(
const message = const message =
remindersToSend remindersToSend
.map((i) => { .map((i) => {
const takenByStr = const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`; let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
if (i.pillWeightMg) { if (i.pillWeightMg) {
const totalMg = i.usage * i.pillWeightMg; const totalMg = i.usage * i.pillWeightMg;
@@ -625,7 +660,7 @@ async function checkAndSendIntakeRemindersForUser(
// Get the first reminder's medication name and taken by for display // Get the first reminder's medication name and taken by for display
const firstReminder = remindersToSend[0]; const firstReminder = remindersToSend[0];
const medName = firstReminder?.medName; const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy?.length > 0 ? firstReminder.takenBy.join(", ") : undefined; const takenBy = firstReminder?.takenBy || undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy); await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
} }
} }
+58
View File
@@ -682,4 +682,62 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.statusCode).toBe(401); expect(response.statusCode).toBe(401);
}); });
}); });
describe("DELETE /auth/me - Delete Account", () => {
it("should delete user account and all data", async () => {
// Register and login
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const login = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
// Delete account
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
cookies: {
access_token: accessToken?.value ?? "",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
// Verify can't login anymore
const loginAgain = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
expect(loginAgain.statusCode).toBe(401);
});
it("should reject delete without auth", async () => {
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
});
expect(response.statusCode).toBe(401);
});
});
}); });
+27 -23
View File
@@ -76,29 +76,33 @@ async function createSchema(client: Client) {
updated_at integer NOT NULL DEFAULT (strftime('%s','now')) updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`, )`,
`CREATE TABLE IF NOT EXISTS medications ( `CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL, user_id integer NOT NULL,
name text NOT NULL, name text NOT NULL,
generic_name text, generic_name text,
taken_by_json text NOT NULL DEFAULT '[]', taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1, package_type text NOT NULL DEFAULT 'blister',
blisters_per_pack integer NOT NULL DEFAULT 1, pack_count integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1, blisters_per_pack integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0, pills_per_blister integer NOT NULL DEFAULT 1,
stock_adjustment integer NOT NULL DEFAULT 0, total_pills integer,
last_stock_correction_at integer, loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer, stock_adjustment integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]', last_stock_correction_at integer,
every_json text NOT NULL DEFAULT '[]', pill_weight_mg integer,
start_json text NOT NULL DEFAULT '[]', dose_unit text DEFAULT 'mg',
image_url text, usage_json text NOT NULL DEFAULT '[]',
expiry_date text, every_json text NOT NULL DEFAULT '[]',
notes text, start_json text NOT NULL DEFAULT '[]',
intake_reminders_enabled integer NOT NULL DEFAULT 0, intakes_json text NOT NULL DEFAULT '[]',
dismissed_until text, image_url text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')), expiry_date text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE notes text,
)`, intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
+27 -23
View File
@@ -71,29 +71,33 @@ async function createSchema(client: Client) {
updated_at integer NOT NULL DEFAULT (strftime('%s','now')) updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`, )`,
`CREATE TABLE IF NOT EXISTS medications ( `CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL, user_id integer NOT NULL,
name text NOT NULL, name text NOT NULL,
generic_name text, generic_name text,
taken_by_json text NOT NULL DEFAULT '[]', taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1, package_type text NOT NULL DEFAULT 'blister',
blisters_per_pack integer NOT NULL DEFAULT 1, pack_count integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1, blisters_per_pack integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0, pills_per_blister integer NOT NULL DEFAULT 1,
stock_adjustment integer NOT NULL DEFAULT 0, total_pills integer,
last_stock_correction_at integer, loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer, stock_adjustment integer NOT NULL DEFAULT 0,
usage_json text NOT NULL DEFAULT '[]', last_stock_correction_at integer,
every_json text NOT NULL DEFAULT '[]', pill_weight_mg integer,
start_json text NOT NULL DEFAULT '[]', dose_unit text DEFAULT 'mg',
image_url text, usage_json text NOT NULL DEFAULT '[]',
expiry_date text, every_json text NOT NULL DEFAULT '[]',
notes text, start_json text NOT NULL DEFAULT '[]',
intake_reminders_enabled integer NOT NULL DEFAULT 0, intakes_json text NOT NULL DEFAULT '[]',
dismissed_until text, image_url text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')), expiry_date text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE notes text,
)`, intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
+48 -33
View File
@@ -16,12 +16,24 @@ import {
getTodayInTimezone, getTodayInTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type Intake,
parseBlisters, parseBlisters,
parseIntakeReminderState, parseIntakeReminderState,
parseReminderState, parseReminderState,
parseTakenByJson, parseTakenByJson,
} from "../utils/scheduler-utils.js"; } from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
function blisterToIntake(blister: Blister, takenBy: string | null = null, intakeRemindersEnabled = false): Intake {
return {
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy,
intakeRemindersEnabled,
};
}
describe("Scheduler Utils - Timezone Functions", () => { describe("Scheduler Utils - Timezone Functions", () => {
let originalTz: string | undefined; let originalTz: string | undefined;
@@ -333,45 +345,45 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
describe("getUpcomingIntakes", () => { describe("getUpcomingIntakes", () => {
it("should return empty array when no intakes in window", () => { it("should return empty array when no intakes in window", () => {
// With parseLocalDateTime, times are treated as local - use same format for consistency // With parseLocalDateTime, times are treated as local - use same format for consistency
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }]; const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
// Set "now" to a time far from any scheduled intake (12:00 local) // Set "now" to a time far from any scheduled intake (12:00 local)
const now = new Date(2025, 0, 1, 12, 0, 0).getTime(); const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it("should find intake within reminder window", () => { it("should find intake within reminder window", () => {
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before) // Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00" }]; const intakes: Intake[] = [blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:00:00" }, "Alice")];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now); const result = getUpcomingIntakes("TestMed", intakes, 15, [], 500, "en-US", "UTC", now);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].medName).toBe("TestMed"); expect(result[0].medName).toBe("TestMed");
expect(result[0].usage).toBe(2); expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toEqual(["Alice"]); expect(result[0].takenBy).toBe("Alice");
expect(result[0].pillWeightMg).toBe(500); expect(result[0].pillWeightMg).toBe(500);
}); });
it("should skip blisters with zero interval", () => { it("should skip blisters with zero interval", () => {
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00" }]; const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it("should handle multiple blisters", () => { it("should handle multiple blisters", () => {
// Two intakes at 08:00 and 08:01 local // Two intakes at 08:00 and 08:01 local
const blisters: Blister[] = [ const intakes: Intake[] = [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }, blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }),
{ usage: 2, every: 1, start: "2025-01-01T08:01:00" }, blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }),
]; ];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime(); const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now); const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Both should be found as they're within the window // Both should be found as they're within the window
expect(result.length).toBeGreaterThanOrEqual(1); expect(result.length).toBeGreaterThanOrEqual(1);
@@ -382,10 +394,10 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
it("should return all intakes for today", () => { it("should return all intakes for today", () => {
// Daily medication at 08:00 starting yesterday // Daily medication at 08:00 starting yesterday
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time // With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }]; const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" })];
// Get intakes for today (today's intake should be at 08:00 local) // Get intakes for today (today's intake should be at 08:00 local)
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC"); const result = getTodaysIntakes("TestMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(1); expect(result.length).toBeGreaterThanOrEqual(1);
const intake = result.find((i) => i.intakeTime.getHours() === 8); const intake = result.find((i) => i.intakeTime.getHours() === 8);
@@ -399,20 +411,23 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 1, 0, 0); todayMidnight.setUTCHours(0, 1, 0, 0);
const blisters: Blister[] = [ const intakes: Intake[] = [
{ blisterToIntake(
usage: 2, {
every: 1, usage: 2,
start: todayMidnight.toISOString(), every: 1,
}, start: todayMidnight.toISOString(),
},
"Bob"
),
]; ];
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC"); const result = getTodaysIntakes("PastMed", intakes, [], 250, "en-US", "UTC");
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].medName).toBe("PastMed"); expect(result[0].medName).toBe("PastMed");
expect(result[0].usage).toBe(2); expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toEqual(["Bob"]); expect(result[0].takenBy).toBe("Bob");
expect(result[0].pillWeightMg).toBe(250); expect(result[0].pillWeightMg).toBe(250);
}); });
@@ -424,12 +439,12 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const evening = new Date(today); const evening = new Date(today);
evening.setUTCHours(20, 0, 0, 0); evening.setUTCHours(20, 0, 0, 0);
const blisters: Blister[] = [ const intakes: Intake[] = [
{ usage: 1, every: 1, start: morning.toISOString() }, blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }),
{ usage: 1, every: 1, start: evening.toISOString() }, blisterToIntake({ usage: 1, every: 1, start: evening.toISOString() }),
]; ];
const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC"); const result = getTodaysIntakes("MultiMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(2); expect(result.length).toBeGreaterThanOrEqual(2);
}); });
@@ -439,16 +454,16 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const lastWeek = new Date(); const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7); lastWeek.setDate(lastWeek.getDate() - 7);
const blisters: Blister[] = [ const intakes: Intake[] = [
{ blisterToIntake({
usage: 1, usage: 1,
every: 7, every: 7,
start: lastWeek.toISOString(), start: lastWeek.toISOString(),
}, }),
]; ];
// If today is not the same day of week, should return empty // If today is not the same day of week, should return empty
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC"); const result = getTodaysIntakes("WeeklyMed", intakes, [], null, "en-US", "UTC");
// This test might return 0 or 1 depending on the day // This test might return 0 or 1 depending on the day
expect(Array.isArray(result)).toBe(true); expect(Array.isArray(result)).toBe(true);
@@ -458,15 +473,15 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time // With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin) // The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time // So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
const blisters: Blister[] = [ const intakes: Intake[] = [
{ blisterToIntake({
usage: 1, usage: 1,
every: 1, every: 1,
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time
}, }),
]; ];
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin"); const result = getTodaysIntakes("TzMed", intakes, [], null, "de-DE", "Europe/Berlin");
expect(Array.isArray(result)).toBe(true); expect(Array.isArray(result)).toBe(true);
if (result.length > 0) { if (result.length > 0) {
+130 -21
View File
@@ -5,8 +5,18 @@
import { getDateLocale, type Language } from "../i18n/translations.js"; import { getDateLocale, type Language } from "../i18n/translations.js";
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string }; export type Blister = { usage: number; every: number; start: string };
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
};
// ============================================================================= // =============================================================================
// Timezone utilities // Timezone utilities
// ============================================================================= // =============================================================================
@@ -147,7 +157,7 @@ export function parseLocalDateTime(isoString: string): Date {
); );
} }
/** Parse blister schedules from JSON columns */ /** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] { export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try { try {
const usage = JSON.parse(row.usageJson) as number[]; const usage = JSON.parse(row.usageJson) as number[];
@@ -164,6 +174,59 @@ export function parseBlisters(row: { usageJson: string; everyJson: string; start
} }
} }
/**
* Parse intakes from the new unified intakesJson format.
* Falls back to legacy parallel arrays if intakesJson is empty.
* @param intakesJson - The new unified JSON string
* @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback
* @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy)
*/
export function parseIntakesJson(
intakesJson: string | null | undefined,
legacyRow?: { usageJson: string; everyJson: string; startJson: string },
medicationIntakeRemindersEnabled?: boolean
): Intake[] {
// Try new format first
if (intakesJson) {
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: any) => ({
usage: typeof intake.usage === "number" ? intake.usage : 0,
every: typeof intake.every === "number" ? intake.every : 1,
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
}
} catch {
// Fall through to legacy parsing
}
}
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
}
return [];
}
/**
* Convert intakes to legacy blister format (for backward compatibility)
*/
export function intakesToBlisters(intakes: Intake[]): Blister[] {
return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
/** Parse takenByJson to array of strings */ /** Parse takenByJson to array of strings */
export function parseTakenByJson(takenByJson: string | null | undefined): string[] { export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return []; if (!takenByJson) return [];
@@ -175,6 +238,28 @@ export function parseTakenByJson(takenByJson: string | null | undefined): string
} }
} }
/**
* Get all unique takenBy values from both medication-level and intake-level.
* Used for filtering and sharing functionality.
*/
export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] {
const allPeople = new Set<string>(medicationTakenBy);
for (const intake of intakes) {
if (intake.takenBy) {
allPeople.add(intake.takenBy);
}
}
return Array.from(allPeople);
}
/**
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
*/
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
if (medicationTakenBy.includes(person)) return true;
return intakes.some((intake) => intake.takenBy === person);
}
// ============================================================================= // =============================================================================
// Stock calculation utilities // Stock calculation utilities
// ============================================================================= // =============================================================================
@@ -209,24 +294,30 @@ export function calculateDepletionInfo(
export type UpcomingIntake = { export type UpcomingIntake = {
medName: string; medName: string;
medicationId?: number;
blisterIndex?: number;
usage: number; usage: number;
intakeTime: Date; intakeTime: Date;
intakeTimeStr: string; intakeTimeStr: string;
takenBy: string[]; takenBy: string | null; // Single person for this intake (null = no specific person)
pillWeightMg: number | null; pillWeightMg: number | null;
doseUnit?: string;
}; };
/** /**
* Get all intakes for today (past and future) - used for repeat reminders. * Get all intakes for today (past and future) - used for repeat reminders.
* Returns all intakes scheduled for today in user's timezone. * Returns all intakes scheduled for today in user's timezone.
* Now uses per-intake takenBy instead of medication-level.
*/ */
export function getTodaysIntakes( export function getTodaysIntakes(
medName: string, medName: string,
blisters: Blister[], intakes: Intake[],
takenBy: string[], medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null, pillWeightMg: number | null,
locale: string, locale: string,
tz?: string tz?: string,
medicationId?: number,
doseUnit?: string
): UpcomingIntake[] { ): UpcomingIntake[] {
const timezone = tz ?? getTimezone(); const timezone = tz ?? getTimezone();
const now = new Date(); const now = new Date();
@@ -238,14 +329,19 @@ export function getTodaysIntakes(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone })); const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
todayEnd.setHours(23, 59, 59, 999); todayEnd.setHours(23, 59, 59, 999);
const intakes: UpcomingIntake[] = []; const result: UpcomingIntake[] = [];
for (const blister of blisters) { for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const startTime = parseLocalDateTime(blister.start).getTime(); const intake = intakes[blisterIdx];
const intervalMs = blister.every * 24 * 60 * 60 * 1000; const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue; if (intervalMs <= 0) continue;
// Determine takenBy for this intake
// If intake has its own takenBy, use it; otherwise null (no specific person)
const effectiveTakenBy = intake.takenBy || null;
// Find all occurrences that fall within today // Find all occurrences that fall within today
let currentTime = startTime; let currentTime = startTime;
@@ -260,39 +356,45 @@ export function getTodaysIntakes(
while (currentTime <= todayEnd.getTime()) { while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) { if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime); const intakeDate = new Date(currentTime);
intakes.push({ result.push({
medName, medName,
usage: blister.usage, medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate, intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, { intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone: timezone, timeZone: timezone,
}), }),
takenBy, takenBy: effectiveTakenBy,
pillWeightMg, pillWeightMg,
doseUnit,
}); });
} }
currentTime += intervalMs; currentTime += intervalMs;
} }
} }
return intakes; return result;
} }
/** /**
* Get upcoming intakes that fall within the reminder window. * Get upcoming intakes that fall within the reminder window.
* Returns intakes that should be notified about right now. * Returns intakes that should be notified about right now.
* Now uses per-intake takenBy instead of medication-level.
*/ */
export function getUpcomingIntakes( export function getUpcomingIntakes(
medName: string, medName: string,
blisters: Blister[], intakes: Intake[],
minutesBefore: number, minutesBefore: number,
takenBy: string[], medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null, pillWeightMg: number | null,
locale: string, locale: string,
tz?: string, tz?: string,
nowOverride?: number nowOverride?: number,
medicationId?: number,
doseUnit?: string
): UpcomingIntake[] { ): UpcomingIntake[] {
const now = nowOverride ?? Date.now(); const now = nowOverride ?? Date.now();
const timezone = tz ?? getTimezone(); const timezone = tz ?? getTimezone();
@@ -303,12 +405,16 @@ export function getUpcomingIntakes(
const upcoming: UpcomingIntake[] = []; const upcoming: UpcomingIntake[] = [];
for (const blister of blisters) { for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
const startTime = parseLocalDateTime(blister.start).getTime(); const intake = intakes[blisterIdx];
const intervalMs = blister.every * 24 * 60 * 60 * 1000; const startTime = parseLocalDateTime(intake.start).getTime();
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue; if (intervalMs <= 0) continue;
// Determine takenBy for this intake
const effectiveTakenBy = intake.takenBy || null;
// Find the next scheduled intake time (could be today or in the future) // Find the next scheduled intake time (could be today or in the future)
let nextTime = startTime; let nextTime = startTime;
@@ -339,15 +445,18 @@ export function getUpcomingIntakes(
const intakeDate = new Date(nextTime); const intakeDate = new Date(nextTime);
upcoming.push({ upcoming.push({
medName, medName,
usage: blister.usage, medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate, intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, { intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone: timezone, timeZone: timezone,
}), }),
takenBy, takenBy: effectiveTakenBy,
pillWeightMg, pillWeightMg,
doseUnit,
}); });
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.5.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.5.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"i18next": "^24.2.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.5.0", "version": "1.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,6 +12,7 @@
"format": "npx biome format --write .", "format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit", "check": "npx biome check . && tsc --noEmit",
"test": "vitest", "test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
+8 -5
View File
@@ -44,7 +44,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<div className="auth-error" style={{ marginBottom: "1rem" }}> <div className="auth-error" style={{ marginBottom: "1rem" }}>
<strong>Connection Error</strong> <strong>Connection Error</strong>
<br /> <br />
@@ -78,7 +78,7 @@ function AppRouter() {
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}> <div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<p>Initializing...</p> <p>Initializing...</p>
</div> </div>
</div> </div>
@@ -185,6 +185,9 @@ function AppContent() {
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// Get centralized stockThresholds from context
const { stockThresholds } = ctx;
// Close modal on Escape key // Close modal on Escape key
useEffect(() => { useEffect(() => {
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
@@ -417,7 +420,7 @@ function AppContent() {
<MedDetailModal <MedDetailModal
selectedMed={selectedMed} selectedMed={selectedMed}
coverage={coverage} coverage={coverage}
settings={settings} settings={stockThresholds}
showImageLightbox={showImageLightbox} showImageLightbox={showImageLightbox}
showRefillModal={showRefillModal} showRefillModal={showRefillModal}
showEditStockModal={showEditStockModal} showEditStockModal={showEditStockModal}
@@ -450,7 +453,7 @@ function AppContent() {
selectedUser={selectedUser} selectedUser={selectedUser}
meds={meds} meds={meds}
coverage={coverage} coverage={coverage}
settings={settings} settings={stockThresholds}
onClose={closeUserFilter} onClose={closeUserFilter}
onOpenMedDetail={openMedDetail} onOpenMedDetail={openMedDetail}
/> />
+1 -1
View File
@@ -80,7 +80,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
<path d="M9 2h6M12 2v2" /> <path d="M9 2h6M12 2v2" />
</svg> </svg>
</div> </div>
<h2>{t("about.appName", "MedAssist")}</h2> <h2>{t("about.appName", "MedAssist-ng")}</h2>
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p> <p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
</div> </div>
<div className="about-versions"> <div className="about-versions">
+87 -18
View File
@@ -1,5 +1,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
import { PasswordInput } from "./PasswordInput";
// ============================================================================= // =============================================================================
// Types (no roles - all users are equal) // Types (no roles - all users are equal)
@@ -32,6 +34,7 @@ interface AuthContextType {
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>; updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>; uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>; deleteAvatar: () => Promise<void>;
deleteAccount: () => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>; authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
} }
@@ -87,7 +90,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authState?.authEnabled]); }, [user, authState?.authEnabled]);
async function fetchAuthState() { async function fetchAuthState(retryCount = 0) {
const maxRetries = 3;
const retryDelay = 1000; // 1 second
try { try {
setAuthError(null); setAuthError(null);
const res = await fetch("/api/auth/state"); const res = await fetch("/api/auth/state");
@@ -101,10 +107,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (state.authEnabled) { if (state.authEnabled) {
await refreshUser(); await refreshUser();
} }
setLoading(false);
} catch (err) { } catch (err) {
console.error("Failed to fetch auth state:", err); console.error(`Failed to fetch auth state (attempt ${retryCount + 1}/${maxRetries + 1}):`, err);
// Retry on connection errors or 5xx errors (server might be restarting)
if (retryCount < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return fetchAuthState(retryCount + 1);
}
setAuthError(err instanceof Error ? err.message : "Failed to connect to server"); setAuthError(err instanceof Error ? err.message : "Failed to connect to server");
} finally {
setLoading(false); setLoading(false);
} }
} }
@@ -244,6 +257,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await refreshUser(); await refreshUser();
} }
// Delete account
async function deleteAccount() {
const res = await fetch("/api/auth/me", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Delete failed" }));
throw new Error(err.error || "Delete failed");
}
setUser(null);
}
// Fetch wrapper that automatically refreshes token on 401 // Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback( const authFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -285,6 +313,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
updateProfile, updateProfile,
uploadAvatar, uploadAvatar,
deleteAvatar, deleteAvatar,
deleteAccount,
authFetch, authFetch,
}} }}
> >
@@ -329,7 +358,7 @@ export function LoginForm({
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<h2 className="auth-subtitle">{t("auth.login", "Login")}</h2> <h2 className="auth-subtitle">{t("auth.login", "Login")}</h2>
{/* SSO Login Button */} {/* SSO Login Button */}
@@ -374,9 +403,8 @@ export function LoginForm({
<div className="form-group"> <div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")}</label> <label htmlFor="password">{t("auth.password", "Password")}</label>
<input <PasswordInput
id="password" id="password"
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -445,7 +473,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card"> <div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1> <h1 className="auth-title">💊 MedAssist-ng</h1>
<h2 className="auth-subtitle">{t("auth.register", "Create Account")}</h2> <h2 className="auth-subtitle">{t("auth.register", "Create Account")}</h2>
{/* SSO Login Button - also show on registration */} {/* SSO Login Button - also show on registration */}
@@ -494,9 +522,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<div className="form-group"> <div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")} *</label> <label htmlFor="password">{t("auth.password", "Password")} *</label>
<input <PasswordInput
id="password" id="password"
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -508,9 +535,8 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
<div className="form-group"> <div className="form-group">
<label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label> <label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label>
<input <PasswordInput
id="confirmPassword" id="confirmPassword"
type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
required required
@@ -541,7 +567,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
// ============================================================================= // =============================================================================
export function UserProfile({ onClose }: { onClose?: () => void }) { export function UserProfile({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth(); const { user, updateProfile, uploadAvatar, deleteAvatar, deleteAccount } = useAuth();
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@@ -549,6 +575,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [success, setSuccess] = useState(""); const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Close on Escape key // Close on Escape key
@@ -625,6 +653,18 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
} }
} }
async function handleDeleteAccount() {
setDeleteLoading(true);
setError("");
try {
await deleteAccount();
// User will be logged out automatically
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
setDeleteLoading(false);
}
}
if (!user) return null; if (!user) return null;
const hasChanges = currentPassword || newPassword || confirmPassword; const hasChanges = currentPassword || newPassword || confirmPassword;
@@ -680,9 +720,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group"> <div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label> <label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input <PasswordInput
id="current-password" id="current-password"
type="password"
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
@@ -692,9 +731,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group"> <div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label> <label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input <PasswordInput
id="new-password" id="new-password"
type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
@@ -705,9 +743,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="form-group"> <div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label> <label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input <PasswordInput
id="confirm-new-password" id="confirm-new-password"
type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
@@ -725,6 +762,38 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
</button> </button>
</div> </div>
</form> </form>
{/* Delete Account Section */}
<div className="profile-section profile-danger-zone">
<h3 className="profile-section-title">{t("auth.deleteAccount", "Delete Account")}</h3>
<button type="button" className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
{t("auth.deleteAccount", "Delete Account")}
</button>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<ConfirmModal
title={t("auth.deleteAccountConfirmTitle", "Delete Account?")}
message={
<>
<p>
{t(
"auth.deleteAccountConfirmText",
"This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone."
)}
</p>
{error && <div className="auth-error">{error}</div>}
</>
}
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
isLoading={deleteLoading}
confirmVariant="danger"
/>
)}
</div> </div>
); );
} }
+48 -31
View File
@@ -171,25 +171,30 @@ export function MedDetailModal({
<div className="med-detail-section"> <div className="med-detail-section">
<h3>{t("modal.stockInfo")}</h3> <h3>{t("modal.stockInfo")}</h3>
<div className="med-detail-grid"> <div className="med-detail-grid">
<div className="med-detail-item"> {selectedMed.packageType === "blister" && (
<span className="med-detail-label">{t("table.fullBlisters")}</span> <>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span> <div className="med-detail-item">
</div> <span className="med-detail-label">{t("table.fullBlisters")}</span>
<div className="med-detail-item"> <span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span className="med-detail-label">{t("table.openBlister")}</span> </div>
<span className={`med-detail-value ${textClass}`}> <div className="med-detail-item">
{formatOpenBlisterAndLoose( <span className="med-detail-label">{t("table.openBlister")}</span>
stock.openBlisterPills, <span className={`med-detail-value ${textClass}`}>
stock.loosePills, {formatOpenBlisterAndLoose(
selectedMed.pillsPerBlister ?? 1, stock.openBlisterPills,
t stock.loosePills,
)} selectedMed.pillsPerBlister ?? 1,
</span> t
</div> )}
<div className="med-detail-item full-width"> </span>
</div>
</>
)}
<div className={`med-detail-item ${selectedMed.packageType === "bottle" ? "full-width" : "full-width"}`}>
<span className="med-detail-label">{t("modal.currentStock")}</span> <span className="med-detail-label">{t("modal.currentStock")}</span>
<span className={`med-detail-value ${textClass}`}> <span className={`med-detail-value ${textClass}`}>
{currentStock} / {packageSize} {currentStock} /{" "}
{selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : packageSize}
</span> </span>
</div> </div>
</div> </div>
@@ -199,22 +204,33 @@ export function MedDetailModal({
<div className="med-detail-section"> <div className="med-detail-section">
<h3>{t("modal.packageDetails")}</h3> <h3>{t("modal.packageDetails")}</h3>
<div className="med-detail-grid"> <div className="med-detail-grid">
<div className="med-detail-item"> {selectedMed.packageType === "blister" ? (
<span className="med-detail-label">{t("modal.packs")}</span> <>
<span className="med-detail-value">{selectedMed.packCount}</span> <div className="med-detail-item">
</div> <span className="med-detail-label">{t("modal.packs")}</span>
<div className="med-detail-item"> <span className="med-detail-value">{selectedMed.packCount}</span>
<span className="med-detail-label">{t("modal.blistersPerPack")}</span> </div>
<span className="med-detail-value">{selectedMed.blistersPerPack}</span> <div className="med-detail-item">
</div> <span className="med-detail-label">{t("modal.blistersPerPack")}</span>
<div className="med-detail-item"> <span className="med-detail-value">{selectedMed.blistersPerPack}</span>
<span className="med-detail-label">{t("modal.pillsPerBlister")}</span> </div>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span> <div className="med-detail-item">
</div> <span className="med-detail-label">{t("modal.pillsPerBlister")}</span>
<span className="med-detail-value">{selectedMed.pillsPerBlister}</span>
</div>
</>
) : (
<div className="med-detail-item">
<span className="med-detail-label">{t("form.totalCapacity")}</span>
<span className="med-detail-value">{selectedMed.totalPills ?? "—"}</span>
</div>
)}
{selectedMed.pillWeightMg && ( {selectedMed.pillWeightMg && (
<div className="med-detail-item"> <div className="med-detail-item">
<span className="med-detail-label">{t("modal.pillWeight")}</span> <span className="med-detail-label">{t("modal.pillWeight")}</span>
<span className="med-detail-value">{selectedMed.pillWeightMg} mg</span> <span className="med-detail-value">
{selectedMed.pillWeightMg} {selectedMed.doseUnit ?? "mg"}
</span>
</div> </div>
)} )}
{selectedMed.expiryDate && ( {selectedMed.expiryDate && (
@@ -253,7 +269,8 @@ export function MedDetailModal({
<div key={idx} className="med-schedule-item"> <div key={idx} className="med-schedule-item">
<span className="med-schedule-usage"> <span className="med-schedule-usage">
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")} {totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
{selectedMed.pillWeightMg && ` (${totalUsage * selectedMed.pillWeightMg} mg)`} {selectedMed.pillWeightMg &&
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
</span> </span>
<span className="med-schedule-freq"> <span className="med-schedule-freq">
{t("form.blisters.every")} {blister.every}{" "} {t("form.blisters.every")} {blister.every}{" "}
+145 -73
View File
@@ -3,7 +3,9 @@
* Handles new medication creation and editing existing medications * Handles new medication creation and editing existing medications
*/ */
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types"; import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import { DOSE_UNITS } from "../types";
import { deriveTotal } from "../utils";
// Field limits for validation // Field limits for validation
const FIELD_LIMITS = { const FIELD_LIMITS = {
@@ -30,10 +32,14 @@ export interface MobileEditModalProps {
onAddTakenByPerson: (person: string) => void; onAddTakenByPerson: (person: string) => void;
onRemoveTakenByPerson: (person: string) => void; onRemoveTakenByPerson: (person: string) => void;
onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; onTakenByKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
// Blister helpers // Blister helpers (legacy)
onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void; onSetBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
onAddBlister: () => void; onAddBlister: () => void;
onRemoveBlister: (idx: number) => void; onRemoveBlister: (idx: number) => void;
// Intake helpers (new - with per-intake takenBy)
onSetIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
onAddIntake: (takenBy?: string) => void;
onRemoveIntake: (idx: number) => void;
// Value change handler for numeric fields // Value change handler for numeric fields
onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void; onHandleValueChange: <K extends keyof FormState>(field: K, value: string) => void;
// Refill state (for edit mode) // Refill state (for edit mode)
@@ -53,12 +59,17 @@ export interface MobileEditModalProps {
onSaveMedication: (e: React.FormEvent) => void; onSaveMedication: (e: React.FormEvent) => void;
} }
function deriveTotal(form: FormState) { /** Calculate total pills from form state */
function deriveTotalFromForm(form: FormState) {
if (form.packageType === "bottle") {
// For bottle type, looseTablets is the current stock
return Number(form.looseTablets) || 0;
}
const packCount = Number(form.packCount) || 0; const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1; const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0; const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets; return deriveTotal(packCount, blistersPerPack, pillsPerBlister, looseTablets);
} }
export function MobileEditModal({ export function MobileEditModal({
@@ -80,6 +91,9 @@ export function MobileEditModal({
onSetBlisterValue, onSetBlisterValue,
onAddBlister, onAddBlister,
onRemoveBlister, onRemoveBlister,
onSetIntakeValue,
onAddIntake,
onRemoveIntake,
onHandleValueChange, onHandleValueChange,
refillPacks, refillPacks,
onRefillPacksChange, onRefillPacksChange,
@@ -178,57 +192,106 @@ export function MobileEditModal({
</div> </div>
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>} {fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label> </label>
<label> <label className="full">
{t("form.packs")} {t("form.packageType")}
<input <select
type="number" className="package-type-select"
min="0" value={form.packageType}
value={form.packCount} onChange={(e) => onHandleValueChange("packageType", e.target.value)}
onChange={(e) => onHandleValueChange("packCount", e.target.value)} >
/> <option value="blister">{t("form.packageTypeBlister")}</option>
</label> <option value="bottle">{t("form.packageTypeBottle")}</option>
<label> </select>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label> </label>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="0"
value={form.blistersPerPack}
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="number"
min="1"
value={form.totalPills}
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<div className="full"> <div className="full">
<p className="sub"> <p className="sub">
<strong>{t("form.total")}:</strong> {deriveTotal(form)} {t("common.pills")} <strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)} {t("common.pills")}
</p> </p>
</div> </div>
<label className="full"> <label className="full">
{t("form.pillWeight")} {t("form.pillWeight")} ({form.doseUnit})
<input <div className="dose-input-group">
type="number" <input
min="0" type="number"
step="0.1" min="0"
value={form.pillWeightMg} step="0.1"
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })} value={form.pillWeightMg}
placeholder={t("form.placeholders.weight")} onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
/> placeholder={t("form.placeholders.weight")}
/>
<select
value={form.doseUnit}
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
className="dose-unit-select"
>
{DOSE_UNITS.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</label> </label>
<label className="full"> <label className="full">
{t("form.expiryDate")} {t("form.expiryDate")}
@@ -325,19 +388,8 @@ export function MobileEditModal({
) : null} ) : null}
<fieldset className="full blister-section"> <fieldset className="full blister-section">
<legend> <legend>{t("form.blisters.title")}</legend>
{t("form.blisters.title")} {form.intakes.map((intake, idx) => (
<label className="toggle-switch small" title={t("form.blisters.remindTooltip")}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => onFormChange({ ...form, intakeRemindersEnabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
<span className="legend-hint">{t("form.blisters.remind")}</span>
</legend>
{form.blisters.map((b, idx) => (
<div key={idx} className="blister-row"> <div key={idx} className="blister-row">
<label className="compact"> <label className="compact">
<span>{t("form.blisters.usage")}</span> <span>{t("form.blisters.usage")}</span>
@@ -345,8 +397,8 @@ export function MobileEditModal({
type="number" type="number"
min="0" min="0"
step="0.1" step="0.1"
value={b.usage} value={intake.usage}
onChange={(e) => onSetBlisterValue(idx, "usage", e.target.value)} onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
/> />
</label> </label>
<label className="compact"> <label className="compact">
@@ -354,34 +406,54 @@ export function MobileEditModal({
<input <input
type="number" type="number"
min="1" min="1"
value={b.every} value={intake.every}
onChange={(e) => onSetBlisterValue(idx, "every", e.target.value)} onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
/> />
</label> </label>
<label className="compact full-row"> <label className="compact full-row">
<span>{t("form.blisters.startDate")}</span> <span>{t("form.blisters.startDate")}</span>
<input <input
type="date" type="date"
value={b.startDate} value={intake.startDate}
onChange={(e) => onSetBlisterValue(idx, "startDate", e.target.value)} onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
/> />
</label> </label>
<label className="compact time-label"> <label className="compact time-label">
<span>{t("form.blisters.startTime")}</span> <span>{t("form.blisters.startTime")}</span>
<input <input
type="time" type="time"
value={b.startTime} value={intake.startTime}
onChange={(e) => onSetBlisterValue(idx, "startTime", e.target.value)} onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
/> />
</label> </label>
{form.blisters.length > 1 && ( <label className="compact full-row">
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveBlister(idx)}> <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.intakes.length > 1 && (
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
{t("common.remove")} {t("common.remove")}
</button> </button>
)} )}
</div> </div>
))} ))}
<button type="button" className="ghost add-blister" onClick={onAddBlister}> <button type="button" className="ghost add-blister" onClick={() => onAddIntake()}>
+ {t("form.blisters.addIntake")} + {t("form.blisters.addIntake")}
</button> </button>
</fieldset> </fieldset>
+74
View File
@@ -0,0 +1,74 @@
import { useState } from "react";
interface PasswordInputProps {
id: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
autoComplete?: string;
minLength?: number;
maxLength?: number;
placeholder?: string;
}
export function PasswordInput({
id,
value,
onChange,
required,
autoComplete,
minLength,
maxLength,
placeholder,
}: PasswordInputProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="password-input-wrapper">
<input
id={id}
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
required={required}
autoComplete={autoComplete}
minLength={minLength}
maxLength={maxLength}
placeholder={placeholder}
/>
<button
type="button"
className="password-toggle-btn"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
);
}
+219 -189
View File
@@ -19,6 +19,7 @@ export function SharedSchedule() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null); const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set()); const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false); const [showPastDays, setShowPastDays] = useState(false);
const [showFutureDays, setShowFutureDays] = useState(false); const [showFutureDays, setShowFutureDays] = useState(false);
@@ -116,6 +117,7 @@ export function SharedSchedule() {
}, [lightboxImage]); }, [lightboxImage]);
// Load taken doses from server with polling for real-time sync // Load taken doses from server with polling for real-time sync
// Separates taken and dismissed doses (like main app's useDoses hook)
useEffect(() => { useEffect(() => {
if (token) { if (token) {
async function loadTakenDoses() { async function loadTakenDoses() {
@@ -123,12 +125,24 @@ export function SharedSchedule() {
const res = await fetch(`/api/share/${token}/doses`); const res = await fetch(`/api/share/${token}/doses`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); const taken = new Set<string>();
const dismissed = new Set<string>();
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean }>) {
if (d.dismissed) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
}
}
setTakenDoses(taken);
setDismissedDoses(dismissed);
} else { } else {
setTakenDoses(new Set()); setTakenDoses(new Set());
setDismissedDoses(new Set());
} }
} catch { } catch {
setTakenDoses(new Set()); setTakenDoses(new Set());
setDismissedDoses(new Set());
} }
} }
loadTakenDoses(); loadTakenDoses();
@@ -139,21 +153,20 @@ export function SharedSchedule() {
} }
}, [token]); }, [token]);
// Get dose ID with optional person suffix // Get dose ID - for per-intake takenBy, the ID already has the person suffix
function getDoseId(baseDoseId: string, person: string | null): string { // This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id
return person ? `${baseDoseId}-${person}` : baseDoseId; function getDoseId(doseId: string, _person: string | null): string {
// The dose.id already includes the person suffix if there's a per-intake takenBy
return doseId;
} }
// Count taken doses for a day/item // Count taken doses for a day/item (simplified - per-intake takenBy means one person per dose)
function _countTakenDoses(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } { function _countTakenDoses(doses: Array<{ id: string; takenBy: string | null }>): { total: number; taken: number } {
let total = 0; let total = 0;
let taken = 0; let taken = 0;
for (const d of doses) { for (const d of doses) {
const people = (d.takenBy || []).length > 0 ? d.takenBy : [null]; total++;
for (const person of people) { if (takenDoses.has(d.id)) taken++;
total++;
if (takenDoses.has(getDoseId(d.id, person))) taken++;
}
} }
return { total, taken }; return { total, taken };
} }
@@ -260,29 +273,42 @@ export function SharedSchedule() {
usage: number; usage: number;
timeStr: string; timeStr: string;
isPast: boolean; isPast: boolean;
takenBy: string[]; takenBy: string | null; // Per-intake takenBy (single person or null)
dateStr: string; dateStr: string;
}[] = []; }[] = [];
for (const med of data.medications) { for (const med of data.medications) {
med.blisters.forEach((blister, blisterIdx) => { // Use intakes (with per-intake takenBy) if available, fallback to blisters (legacy)
const startDate = new Date(blister.start); const intakes =
med.intakes ||
med.blisters.map((b) => ({ ...b, takenBy: null as string | null, intakeRemindersEnabled: false }));
intakes.forEach((intake, intakeIdx) => {
// Filter: only include intakes for this person (null = everyone, or matches share's takenBy)
if (intake.takenBy !== null && intake.takenBy !== data.takenBy) return;
const startDate = new Date(intake.start);
if (Number.isNaN(startDate.getTime())) return; if (Number.isNaN(startDate.getTime())) return;
// Use the same iteration method as buildSchedulePreview (setDate instead of adding ms) // Use the same iteration method as buildSchedulePreview (setDate instead of adding ms)
// This ensures identical timestamps even across DST changes // This ensures identical timestamps even across DST changes
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) { for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + intake.every)) {
const t = d.getTime(); const t = d.getTime();
const isPast = d < todayStart; const isPast = d < todayStart;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs} // Use date-only timestamp for stable ID (immune to time changes)
const doseId = `${med.id}-${blisterIdx}-${t}`; // This ensures changing intake times doesn't invalidate past dose tracking
// Must match buildSchedulePreview in schedule.ts exactly
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
// Dose ID includes person suffix if there's a per-intake takenBy
const baseDoseId = `${med.id}-${intakeIdx}-${dateOnlyMs}`;
const doseId = intake.takenBy ? `${baseDoseId}-${intake.takenBy}` : baseDoseId;
doses.push({ doses.push({
id: doseId, id: doseId,
when: t, when: t,
medName: med.name, medName: med.name,
usage: blister.usage, usage: intake.usage,
isPast, isPast,
takenBy: med.takenBy || [], takenBy: intake.takenBy, // Per-intake takenBy (string | null)
timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }), timeStr: d.toLocaleTimeString(getSystemLocale(i18n.language), { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), { dateStr: d.toLocaleDateString(getSystemLocale(i18n.language), {
weekday: "short", weekday: "short",
@@ -378,6 +404,39 @@ export function SharedSchedule() {
return doseDateStr <= dismissedUntilDate; return doseDateStr <= dismissedUntilDate;
} }
// Build a map of medication name -> updatedAt timestamp
// Used to filter out doses from previous schedule configurations
const updatedAtByMed = useMemo(() => {
if (!data) return new Map<string, number>();
const map = new Map<string, number>();
for (const med of data.medications) {
if (med.updatedAt) {
const ts = typeof med.updatedAt === "number" ? med.updatedAt : new Date(med.updatedAt).getTime();
if (!Number.isNaN(ts)) {
map.set(med.name, ts);
}
}
}
return map;
}, [data]);
// Helper to check if a dose was scheduled BEFORE the medication was last updated
// If so, it's from a previous schedule configuration and shouldn't count as "missed"
// This matches the main app's isDoseFromPreviousSchedule logic in AppContext.tsx
function isDoseFromPreviousSchedule(doseId: string, medName: string): boolean {
const updatedAtTimestamp = updatedAtByMed.get(medName);
if (!updatedAtTimestamp) return false; // No updatedAt means it was never changed, all doses are valid
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
const parts = doseId.split("-");
if (parts.length < 3) return false;
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(doseTimestamp)) return false;
// If the dose was scheduled before the medication was updated, it's from a previous schedule
return doseTimestamp < updatedAtTimestamp;
}
// Calculate coverage for stock status colors (matches main app logic) // Calculate coverage for stock status colors (matches main app logic)
// This needs to account for taken doses and calculate depletion time // This needs to account for taken doses and calculate depletion time
const { coverageByMed, depletionByMed } = useMemo(() => { const { coverageByMed, depletionByMed } = useMemo(() => {
@@ -386,16 +445,11 @@ export function SharedSchedule() {
const depletion: Record<string, number | null> = {}; const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses // Calculate total pills taken per medication from takenDoses
// Each person's taken dose counts separately toward pills consumed // With per-intake takenBy, each dose.id is unique and already has person suffix if needed
const takenByMed: Record<string, number> = {}; const takenByMed: Record<string, number> = {};
for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) { for (const dose of schedule.flatMap((d) => d.meds.flatMap((m) => m.doses))) {
// Check all person-specific dose IDs for this dose if (takenDoses.has(dose.id)) {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
for (const person of people) {
const doseId = person ? `${dose.id}-${person}` : dose.id;
if (takenDoses.has(doseId)) {
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
}
} }
} }
@@ -403,9 +457,9 @@ export function SharedSchedule() {
const totalCount = getMedTotal(med); const totalCount = getMedTotal(med);
const taken = takenByMed[med.name] || 0; const taken = takenByMed[med.name] || 0;
const currentCount = Math.max(0, totalCount - taken); const currentCount = Math.max(0, totalCount - taken);
// Calculate daily usage from blisters, multiplied by number of people // Calculate daily usage from intakes (or blisters for legacy)
const personCount = Math.max(1, med.takenBy?.length || 1); const intakes = med.intakes || med.blisters;
const dailyUsage = med.blisters.reduce((sum, b) => sum + b.usage / b.every, 0) * personCount; const dailyUsage = intakes.reduce((sum, b) => sum + b.usage / b.every, 0);
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null; const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage }; coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
@@ -455,7 +509,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-loading"> <div className="shared-schedule-loading">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<p>{t("common.loading")}</p> <p>{t("common.loading")}</p>
</div> </div>
</div> </div>
@@ -466,7 +520,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-error expired"> <div className="shared-schedule-error expired">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<div className="expired-icon"></div> <div className="expired-icon"></div>
<h2>{t("share.expired.title")}</h2> <h2>{t("share.expired.title")}</h2>
<p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p> <p className="expired-message">{t("share.expired.message", { takenBy: expiredData.takenBy })}</p>
@@ -485,7 +539,7 @@ export function SharedSchedule() {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
<div className="shared-schedule-error"> <div className="shared-schedule-error">
<h1>💊 MedAssist</h1> <h1>💊 MedAssist-ng</h1>
<p className="error-message">{error || "Unknown error"}</p> <p className="error-message">{error || "Unknown error"}</p>
</div> </div>
</div> </div>
@@ -527,27 +581,31 @@ export function SharedSchedule() {
{pastDays.length > 0 && {pastDays.length > 0 &&
(() => { (() => {
// Count all past doses (for display) // Count all past doses (for display)
const totalPastDoses = pastDays.flatMap((d) => // With per-intake takenBy, each dose.id is unique
d.meds.flatMap((m) => const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
m.doses.flatMap((dose) => { // Count missed doses (not taken AND not dismissed AND not from previous schedule)
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]; // Check: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
})
)
);
// Count missed doses (not taken AND not dismissed)
const missedPastDoses = totalPastDoses.filter((id) => { const missedPastDoses = totalPastDoses.filter((id) => {
if (takenDoses.has(id)) return false; if (takenDoses.has(id)) return false;
// Check if this dose is dismissed // 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("-"); const parts = id.split("-");
if (parts.length >= 3) { if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
const medId = parts[0]; const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId); const med = data?.medications.find((m) => String(m.id) === medId);
if (med && isDoseDismissed(timestamp, med.name)) { if (med) {
return false; // dismissed = not missed const timestamp = parseInt(parts[2], 10);
if (isDoseDismissed(timestamp, med.name)) {
return false; // dismissed = not missed
}
// Check if this dose is from a previous schedule configuration
if (isDoseFromPreviousSchedule(id, med.name)) {
return false; // from previous schedule = not missed
}
} }
} }
return true; // not taken, not dismissed = missed return true; // not taken, not dismissed, not from previous schedule = missed
}).length; }).length;
return ( return (
<div <div
@@ -579,27 +637,32 @@ export function SharedSchedule() {
{/* Past days (when expanded) */} {/* Past days (when expanded) */}
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
// Helper to check if a dose ID is "done" (taken or dismissed) // Helper to check if a dose ID is "done" (taken, dismissed, or from previous schedule)
// Checks: per-dose dismissed flag, medication-level dismissedUntil, and updatedAt
const isDoseIdDone = (doseId: string) => { const isDoseIdDone = (doseId: string) => {
if (takenDoses.has(doseId)) return true; if (takenDoses.has(doseId)) return true;
// Check if dismissed // 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 or from previous schedule
const parts = doseId.split("-"); const parts = doseId.split("-");
if (parts.length >= 3) { if (parts.length >= 3) {
const timestamp = parseInt(parts[2], 10);
const medId = parts[0]; const medId = parts[0];
const med = data?.medications.find((m) => String(m.id) === medId); const med = data?.medications.find((m) => String(m.id) === medId);
if (med && isDoseDismissed(timestamp, med.name)) { if (med) {
return true; const timestamp = parseInt(parts[2], 10);
if (isDoseDismissed(timestamp, med.name)) {
return true;
}
// Check if this dose is from a previous schedule configuration
if (isDoseFromPreviousSchedule(doseId, med.name)) {
return true;
}
} }
} }
return false; return false;
}; };
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
item.doses.flatMap((d) => {
return (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id];
})
);
const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone); const allDayDone = allDoseIds.length > 0 && allDoseIds.every(isDoseIdDone);
const doneCount = allDoseIds.filter(isDoseIdDone).length; const doneCount = allDoseIds.filter(isDoseIdDone).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
@@ -661,9 +724,7 @@ export function SharedSchedule() {
} }
} }
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.map((d) => d.id);
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
// A dose is "done" if taken OR dismissed // A dose is "done" if taken OR dismissed
const allDone = itemDoseIds.every(isDoseIdDone); const allDone = itemDoseIds.every(isDoseIdDone);
@@ -692,58 +753,53 @@ export function SharedSchedule() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; // Check: medication-level dismissedUntil, per-dose dismissed flag, and previous schedule
const isDismissed = isDoseDismissed(dose.when, dose.medName); const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
const allDone = people.every((person) => { const isFromPreviousSchedule = isDoseFromPreviousSchedule(dose.id, dose.medName);
const doseId = getDoseId(dose.id, person); const isTaken = takenDoses.has(dose.id);
return takenDoses.has(doseId) || isDismissed; const isPerDoseDismissed = dismissedDoses.has(dose.id);
}); const isDone =
isTaken || isPerDoseDismissed || isMedLevelDismissed || isFromPreviousSchedule;
return ( return (
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item past ${isDone ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { <div className={`dose-person ${isDone ? "taken" : ""}`}>
const doseId = getDoseId(dose.id, person); {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
const isTaken = takenDoses.has(doseId); {isDone ? (
const isDone = isTaken || isDismissed; isTaken ? (
return ( <button
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}> className="dose-btn undo"
{person && <span className="person-name">{person}</span>} onClick={() => undoDoseTaken(dose.id)}
{isDone ? ( title={t("common.undo")}
isTaken ? ( >
<button
className="dose-btn undo" </button>
onClick={() => undoDoseTaken(doseId)} ) : (
title={t("common.undo")} // Dismissed - show checkmark but no undo
> <span
className="dose-btn dismissed"
</button> title={t("dashboard.schedules.dismissed") ?? "Dismissed"}
) : ( >
// Dismissed - show checkmark but no undo
<span </span>
className="dose-btn dismissed" )
title={t("dashboard.schedules.dismissed") ?? "Dismissed"} ) : (
> <button
className="dose-btn take"
</span> onClick={() => markDoseTaken(dose.id)}
) disabled={isEmpty}
) : ( title={t("dose.markAsTaken")}
<button >
className="dose-btn take"
onClick={() => markDoseTaken(doseId)} </button>
disabled={isEmpty} )}
title={t("dose.markAsTaken")} </div>
>
</button>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -759,11 +815,7 @@ export function SharedSchedule() {
{todayDay && {todayDay &&
(() => { (() => {
const day = todayDay; const day = todayDay;
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
)
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const worstStatus = getDayStockStatus(day.meds); const worstStatus = getDayStockStatus(day.meds);
@@ -818,9 +870,7 @@ export function SharedSchedule() {
} }
} }
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.map((d) => d.id);
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div <div
@@ -847,47 +897,40 @@ export function SharedSchedule() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const isTaken = takenDoses.has(dose.id);
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); const isOverdue = dose.when < Date.now() && !isTaken;
return ( return (
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item ${isTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { <div
const doseId = getDoseId(dose.id, person); className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
const isTaken = takenDoses.has(doseId); >
const isOverdue = dose.when < Date.now() && !isTaken; {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
return ( {isTaken ? (
<div <button
key={doseId} className="dose-btn undo"
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`} onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
> >
{person && <span className="person-name">{person}</span>}
{isTaken ? ( </button>
<button ) : (
className="dose-btn undo" <button
onClick={() => undoDoseTaken(doseId)} className="dose-btn take"
title={t("common.undo")} onClick={() => markDoseTaken(dose.id)}
> title={t("dose.markAsTaken")}
disabled={isEmpty}
</button> >
) : (
<button </button>
className="dose-btn take" )}
onClick={() => markDoseTaken(doseId)} </div>
title={t("dose.markAsTaken")}
disabled={isEmpty}
>
</button>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );
@@ -920,11 +963,7 @@ export function SharedSchedule() {
{showFutureDays && {showFutureDays &&
futureDays.map((day) => { futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse) // Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
item.doses.flatMap((d) =>
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
)
);
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -982,9 +1021,7 @@ export function SharedSchedule() {
} }
} }
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.map((d) => d.id);
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div <div
@@ -1011,56 +1048,49 @@ export function SharedSchedule() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const isTaken = takenDoses.has(dose.id);
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
// Only disable doses on future DAYS, not later today // Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when); const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0); doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date(); const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0); todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime(); const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
return ( return (
<div <div
key={dose.id} key={dose.id}
className={`dose-item ${isFutureDose ? "future" : ""} ${allTaken ? "all-taken" : ""}`} className={`dose-item ${isFutureDose ? "future" : ""} ${isTaken ? "all-taken" : ""}`}
> >
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { <div
const doseId = getDoseId(dose.id, person); className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
const isTaken = takenDoses.has(doseId); >
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose; {dose.takenBy && <span className="person-name">{dose.takenBy}</span>}
return ( {isTaken ? (
<div <button
key={doseId} className="dose-btn undo"
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`} onClick={() => undoDoseTaken(dose.id)}
title={t("common.undo")}
> >
{person && <span className="person-name">{person}</span>}
{isTaken ? ( </button>
<button ) : (
className="dose-btn undo" <button
onClick={() => undoDoseTaken(doseId)} className="dose-btn take"
title={t("common.undo")} onClick={() => markDoseTaken(dose.id)}
> title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
</button> >
) : (
<button </button>
className="dose-btn take" )}
onClick={() => markDoseTaken(doseId)} </div>
title={t("dose.markAsTaken")}
disabled={isFutureDose || isEmpty}
>
</button>
)}
</div>
);
})}
</div> </div>
</div> </div>
); );
+1
View File
@@ -13,6 +13,7 @@ export type { MedicationAvatarProps } from "./MedicationAvatar";
export { MedicationAvatar } from "./MedicationAvatar"; export { MedicationAvatar } from "./MedicationAvatar";
export type { MobileEditModalProps } from "./MobileEditModal"; export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal"; export { MobileEditModal } from "./MobileEditModal";
export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal"; export { default as ProfileModal } from "./ProfileModal";
export type { ShareDialogProps } from "./ShareDialog"; export type { ShareDialogProps } from "./ShareDialog";
export { ShareDialog } from "./ShareDialog"; export { ShareDialog } from "./ShareDialog";
+55 -4
View File
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks"; import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
import type { Coverage, Medication, ScheduleEvent } from "../types"; import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
import { getSystemLocale } from "../utils/formatters"; import { getSystemLocale } from "../utils/formatters";
import { buildSchedulePreview, calculateCoverage } from "../utils/schedule"; import { buildSchedulePreview, calculateCoverage } from "../utils/schedule";
@@ -134,6 +134,7 @@ export interface AppContextValue {
coverage: { all: Coverage[]; low: Coverage[] }; coverage: { all: Coverage[]; low: Coverage[] };
coverageByMed: Record<string, Coverage>; coverageByMed: Record<string, Coverage>;
depletionByMed: Record<string, number | null>; depletionByMed: Record<string, number | null>;
stockThresholds: StockThresholds;
existingPeople: string[]; existingPeople: string[];
groupedSchedule: GroupedDay[]; groupedSchedule: GroupedDay[];
pastDays: GroupedDay[]; pastDays: GroupedDay[];
@@ -296,6 +297,24 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
// Centralized stock thresholds for consistent status display across all components
const stockThresholds: StockThresholds = useMemo(
() => ({
lowStockDays: settingsHook.settings.lowStockDays,
normalStockDays: settingsHook.settings.normalStockDays,
highStockDays: settingsHook.settings.highStockDays,
criticalStockDays: settingsHook.settings.reminderDaysBefore, // Critical uses the reminder threshold
expiryWarningDays: settingsHook.settings.expiryWarningDays,
}),
[
settingsHook.settings.lowStockDays,
settingsHook.settings.normalStockDays,
settingsHook.settings.highStockDays,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.expiryWarningDays,
]
);
const existingPeople = useMemo(() => { const existingPeople = useMemo(() => {
const allPeople = medications.meds.flatMap((m) => m.takenBy || []); const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
return [...new Set(allPeople)].filter(Boolean).sort(); return [...new Set(allPeople)].filter(Boolean).sort();
@@ -419,12 +438,35 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return doseDateStr <= dismissedUntilDate; return doseDateStr <= dismissedUntilDate;
}, []); }, []);
// Helper to check if a dose was scheduled BEFORE the medication was last updated
// If so, it's from a previous schedule configuration and shouldn't count as "missed"
const isDoseFromPreviousSchedule = useCallback(
(doseId: string, medUpdatedAt: string | number | null | undefined): boolean => {
if (!medUpdatedAt) return false; // No updatedAt means it was never changed, all doses are valid
// Extract timestamp from dose ID (format: medId-blisterIdx-timestamp or medId-blisterIdx-timestamp-person)
const parts = doseId.split("-");
if (parts.length < 3) return false;
const doseTimestamp = parseInt(parts[2], 10);
if (Number.isNaN(doseTimestamp)) return false;
// Convert updatedAt to timestamp
const updatedAtTimestamp = typeof medUpdatedAt === "number" ? medUpdatedAt : new Date(medUpdatedAt).getTime();
if (Number.isNaN(updatedAtTimestamp)) return false;
// If the dose was scheduled before the medication was updated, it's from a previous schedule
return doseTimestamp < updatedAtTimestamp;
},
[]
);
const missedPastDoseIds = useMemo(() => { const missedPastDoseIds = useMemo(() => {
const totalPastDoses = pastDays.flatMap((d) => const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => { d.meds.flatMap((m) => {
// Find the medication to get its dismissedUntil // Find the medication to get its dismissedUntil and updatedAt
const med = medications.meds.find((med) => med.name === m.medName); const med = medications.meds.find((med) => med.name === m.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined; const dismissedUntilDate = med?.dismissedUntil ?? undefined;
const medUpdatedAt = med?.updatedAt;
return m.doses.flatMap((dose) => { return m.doses.flatMap((dose) => {
// Check if this dose is on or before the dismissed date for this medication // Check if this dose is on or before the dismissed date for this medication
@@ -432,13 +474,20 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return []; return [];
} }
return (dose.takenBy || []).length > 0 ? dose.takenBy.map((p: string) => `${dose.id}-${p}`) : [dose.id]; // Check if this dose is from a previous schedule configuration
// (scheduled before the medication was last updated)
if (isDoseFromPreviousSchedule(dose.id, medUpdatedAt)) {
return [];
}
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p: string) => `${dose.id}-${p}`) : [dose.id];
}); });
}) })
); );
// Also filter out doses that are marked as taken or individually dismissed (legacy) // Also filter out doses that are marked as taken or individually dismissed (legacy)
return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id)); return totalPastDoses.filter((id) => !doses.takenDoses.has(id) && !doses.dismissedDoses.has(id));
}, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed]); }, [pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses, isDoseDismissed, isDoseFromPreviousSchedule]);
// Modal helpers with browser history support // Modal helpers with browser history support
const openMedDetail = useCallback( const openMedDetail = useCallback(
@@ -769,6 +818,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
coverage, coverage,
coverageByMed, coverageByMed,
depletionByMed, depletionByMed,
stockThresholds,
existingPeople, existingPeople,
groupedSchedule, groupedSchedule,
pastDays, pastDays,
@@ -832,6 +882,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
coverage, coverage,
coverageByMed, coverageByMed,
depletionByMed, depletionByMed,
stockThresholds,
existingPeople, existingPeople,
groupedSchedule, groupedSchedule,
pastDays, pastDays,
+69 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { FieldErrors, FormBlister, FormState, Medication } from "../types"; import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
import { FIELD_LIMITS } from "../types"; import { FIELD_LIMITS } from "../types";
import { toDateValue, toTimeValue } from "../utils/formatters"; import { toDateValue, toTimeValue } from "../utils/formatters";
@@ -14,19 +14,38 @@ export const defaultBlister = (): FormBlister => {
}; };
}; };
/**
* Create a new intake with optional per-intake takenBy
*/
export const defaultIntake = (takenBy: string = ""): FormIntake => {
const now = new Date();
return {
usage: "1",
every: "1",
startDate: toDateValue(now),
startTime: toTimeValue(now),
takenBy, // Per-intake user assignment (empty string = null/everyone)
intakeRemindersEnabled: false,
};
};
export const defaultForm = (): FormState => ({ export const defaultForm = (): FormState => ({
name: "", name: "",
genericName: "", genericName: "",
takenBy: [], takenBy: [],
packageType: "blister",
packCount: "1", packCount: "1",
blistersPerPack: "1", blistersPerPack: "1",
pillsPerBlister: "1", pillsPerBlister: "1",
totalPills: "",
looseTablets: "0", looseTablets: "0",
pillWeightMg: "", pillWeightMg: "",
doseUnit: "mg",
expiryDate: "", expiryDate: "",
notes: "", notes: "",
intakeRemindersEnabled: false, intakeRemindersEnabled: false,
blisters: [defaultBlister()], blisters: [defaultBlister()],
intakes: [defaultIntake()],
}); });
export interface UseMedicationFormReturn { export interface UseMedicationFormReturn {
@@ -53,6 +72,10 @@ export interface UseMedicationFormReturn {
setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void; setBlisterValue: (idx: number, field: keyof FormBlister, value: string) => void;
addBlister: () => void; addBlister: () => void;
removeBlister: (idx: number) => void; removeBlister: (idx: number) => void;
// Intake management with per-intake takenBy
setIntakeValue: (idx: number, field: keyof FormIntake, value: string | boolean) => void;
addIntake: (takenBy?: string) => void;
removeIntake: (idx: number) => void;
startEdit: (med: Medication, openEditModal: () => void) => void; startEdit: (med: Medication, openEditModal: () => void) => void;
resetForm: () => void; resetForm: () => void;
handleValueChange: <K extends keyof FormState>(key: K, value: string) => void; handleValueChange: <K extends keyof FormState>(key: K, value: string) => void;
@@ -134,19 +157,60 @@ export function useMedicationForm(): UseMedicationFormReturn {
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) })); setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
}, []); }, []);
// Intake management with per-intake takenBy
const setIntakeValue = useCallback((idx: number, field: keyof FormIntake, value: string | boolean) => {
setForm((prev) => {
const next = [...prev.intakes];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, intakes: next };
});
}, []);
const addIntake = useCallback((takenBy: string = "") => {
setForm((prev) => ({ ...prev, intakes: [...prev.intakes, defaultIntake(takenBy)] }));
}, []);
const removeIntake = useCallback((idx: number) => {
setForm((prev) => ({ ...prev, intakes: prev.intakes.filter((_, i) => i !== idx) }));
}, []);
const startEdit = useCallback((med: Medication, openEditModal: () => void) => { const startEdit = useCallback((med: Medication, openEditModal: () => void) => {
setEditingId(med.id); setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved setFormSaved(true); // Existing medication is already saved
// Parse intakes - prefer new format, fallback to legacy blisters
const intakesFromApi =
med.intakes && med.intakes.length > 0
? med.intakes.map((i) => ({
usage: String(i.usage),
every: String(i.every),
startDate: toDateValue(i.start),
startTime: toTimeValue(i.start),
takenBy: i.takenBy ?? "", // Convert null to empty string for form
intakeRemindersEnabled: i.intakeRemindersEnabled,
}))
: med.blisters.map((s) => ({
usage: String(s.usage),
every: String(s.every),
startDate: toDateValue(s.start),
startTime: toTimeValue(s.start),
takenBy: "", // Legacy blisters have no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
const editForm: FormState = { const editForm: FormState = {
name: med.name, name: med.name,
genericName: med.genericName ?? "", genericName: med.genericName ?? "",
takenBy: med.takenBy || [], // Already an array from API takenBy: med.takenBy || [], // Already an array from API
packageType: med.packageType ?? "blister",
packCount: String(med.packCount), packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack), blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister), pillsPerBlister: String(med.pillsPerBlister),
totalPills: med.totalPills ? String(med.totalPills) : "",
looseTablets: String(med.looseTablets), looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg",
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "", expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "", notes: med.notes ?? "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false, intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -156,6 +220,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
startDate: toDateValue(s.start), startDate: toDateValue(s.start),
startTime: toTimeValue(s.start), startTime: toTimeValue(s.start),
})), })),
intakes: intakesFromApi,
}; };
setForm(editForm); setForm(editForm);
setOriginalForm(editForm); setOriginalForm(editForm);
@@ -234,6 +299,9 @@ export function useMedicationForm(): UseMedicationFormReturn {
setBlisterValue, setBlisterValue,
addBlister, addBlister,
removeBlister, removeBlister,
setIntakeValue,
addIntake,
removeIntake,
startEdit, startEdit,
resetForm, resetForm,
handleValueChange, handleValueChange,
+4 -3
View File
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
const loadMeds = useCallback(() => { const loadMeds = useCallback(() => {
setLoading(true); setLoading(true);
fetch("/api/medications") fetch("/api/medications", { credentials: "include" })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setMeds(Array.isArray(data) ? data : [])) .then((data) => setMeds(Array.isArray(data) ? data : []))
.catch(() => setMeds([])) .catch(() => setMeds([]))
@@ -31,7 +31,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMed = useCallback( const deleteMed = useCallback(
async (id: number, editingId: number | null, resetForm: () => void) => { async (id: number, editingId: number | null, resetForm: () => void) => {
await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
if (editingId === id) resetForm(); if (editingId === id) resetForm();
loadMeds(); loadMeds();
}, },
@@ -48,6 +48,7 @@ export function useMedications(): UseMedicationsReturn {
const res = await fetch(`/api/medications/${medId}/image`, { const res = await fetch(`/api/medications/${medId}/image`, {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}); });
if (res.ok) { if (res.ok) {
loadMeds(); loadMeds();
@@ -62,7 +63,7 @@ export function useMedications(): UseMedicationsReturn {
const deleteMedImage = useCallback( const deleteMedImage = useCallback(
async (medId: number) => { async (medId: number) => {
await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null); await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
loadMeds(); loadMeds();
}, },
[loadMeds] [loadMeds]
+3
View File
@@ -210,6 +210,7 @@ export function useSettings(): UseSettingsReturn {
await fetch("/api/settings", { await fetch("/api/settings", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => null); }).catch(() => null);
@@ -233,6 +234,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-email", { const res = await fetch("/api/settings/test-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: settings.notificationEmail }), body: JSON.stringify({ email: settings.notificationEmail }),
}); });
const data = await res.json(); const data = await res.json();
@@ -254,6 +256,7 @@ export function useSettings(): UseSettingsReturn {
const res = await fetch("/api/settings/test-shoutrrr", { const res = await fetch("/api/settings/test-shoutrrr", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ url: settings.shoutrrrUrl }), body: JSON.stringify({ url: settings.shoutrrrUrl }),
}); });
const data = await res.json(); const data = await res.json();
+1
View File
@@ -59,6 +59,7 @@ export function useShare(): UseShareReturn {
const res = await fetch("/api/share", { const res = await fetch("/api/share", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
takenBy: shareSelectedPerson, takenBy: shareSelectedPerson,
scheduleDays: shareSelectedDays, scheduleDays: shareSelectedDays,
+39 -13
View File
@@ -17,11 +17,16 @@
}, },
"dashboard": { "dashboard": {
"reorder": { "reorder": {
"title": "Nachbestell-Erinnerung", "title": "Nachll-Erinnerung",
"badge": "Bestandsüberwachung", "badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.", "noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.", "allGood": "Alles in Ordnung, genug Vorrat.",
"lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden" "lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
"lowWarningPrefix": "Genug Vorrat, aber",
"lowWarningSuffix": "wird knapp.",
"lowWarningSuffix_other": "werden knapp.",
"sendReminder": "🔔 Erinnerung jetzt senden"
}, },
"overview": { "overview": {
"title": "Medikamentenübersicht", "title": "Medikamentenübersicht",
@@ -64,8 +69,8 @@
"inDays_one": "in {{days}} Tag", "inDays_one": "in {{days}} Tag",
"inDays_other": "in {{days}} Tagen", "inDays_other": "in {{days}} Tagen",
"noRemindersNeeded": "Keine Erinnerungen nötig", "noRemindersNeeded": "Keine Erinnerungen nötig",
"needReorder": "{{count}} Medikament nachbestellen", "needRefill": "{{count}} Medikament nachllen",
"needReorder_other": "{{count}} Medikamente nachbestellen", "needRefill_other": "{{count}} Medikamente nachllen",
"emptyStock": "{{count}} Medikament leer", "emptyStock": "{{count}} Medikament leer",
"emptyStock_other": "{{count}} Medikamente leer", "emptyStock_other": "{{count}} Medikamente leer",
"lowWarning": "{{count}} Medikament wird knapp", "lowWarning": "{{count}} Medikament wird knapp",
@@ -81,7 +86,10 @@
"criticalMeds": "{{count}} Medikament kritisch", "criticalMeds": "{{count}} Medikament kritisch",
"criticalMeds_other": "{{count}} Medikamente kritisch", "criticalMeds_other": "{{count}} Medikamente kritisch",
"lowMeds": "{{count}} Medikament knapp", "lowMeds": "{{count}} Medikament knapp",
"lowMeds_other": "{{count}} Medikamente knapp" "lowMeds_other": "{{count}} Medikamente knapp",
"daysLeft": "{{days}} Tag übrig",
"daysLeft_other": "{{days}} Tage übrig",
"needsRefill": "Nachfüllen nötig"
} }
}, },
"table": { "table": {
@@ -91,11 +99,16 @@
"currentPills": "Aktuelle Tabletten", "currentPills": "Aktuelle Tabletten",
"fullBlisters": "Volle Blister", "fullBlisters": "Volle Blister",
"openBlister": "Offener Blister", "openBlister": "Offener Blister",
"stock": "Bestand",
"stockDetails": "Details",
"daysLeft": "Tage übrig", "daysLeft": "Tage übrig",
"status": "Bestand", "status": "Status",
"runsOut": "Aufgebraucht", "runsOut": "Aufgebraucht",
"autoRemind": "Auto-Erinnerung", "autoRemind": "Auto-Erinnerung",
"expiry": "Ablaufdatum" "expiry": "Ablaufdatum",
"pillsCount": "{{count}} Tabletten",
"pillsCount_one": "{{count}} Tablette",
"pillsCount_other": "{{count}} Tabletten"
}, },
"medications": { "medications": {
"list": { "list": {
@@ -109,7 +122,8 @@
"blisters": "Blister pro Packung", "blisters": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister", "pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose", "loose": "Lose",
"total": "Gesamt" "total": "Gesamt",
"stock": "Bestand"
} }
}, },
"form": { "form": {
@@ -119,11 +133,16 @@
"commercialName": "Handelsname", "commercialName": "Handelsname",
"genericName": "Wirkstoff", "genericName": "Wirkstoff",
"takenBy": "Eingenommen von", "takenBy": "Eingenommen von",
"packageType": "Verpackungsart",
"packageTypeBlister": "Blisterpackung",
"packageTypeBottle": "Pillendose / Behälter",
"packs": "Packungen", "packs": "Packungen",
"blistersPerPack": "Blister pro Packung", "blistersPerPack": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister", "pillsPerBlister": "Tabletten pro Blister",
"totalCapacity": "Gesamtkapazität",
"currentPills": "Aktuelle Tabletten",
"loosePills": "Lose Tabletten", "loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht (mg)", "pillWeight": "Dosis pro Tablette",
"total": "Gesamt (Tabletten)", "total": "Gesamt (Tabletten)",
"expiryDate": "Ablaufdatum", "expiryDate": "Ablaufdatum",
"notes": "Notizen", "notes": "Notizen",
@@ -147,7 +166,9 @@
"every": "alle", "every": "alle",
"from": "ab", "from": "ab",
"startDate": "Datum", "startDate": "Datum",
"startTime": "Uhrzeit" "startTime": "Uhrzeit",
"takenByIntake": "Eingenommen von",
"takenByEveryone": "Alle"
} }
}, },
"planner": { "planner": {
@@ -253,6 +274,7 @@
}, },
"status": { "status": {
"outOfStock": "Leer", "outOfStock": "Leer",
"criticalStock": "Kritisch",
"lowStock": "Niedrig", "lowStock": "Niedrig",
"normal": "Normal", "normal": "Normal",
"highStock": "Hoch", "highStock": "Hoch",
@@ -309,7 +331,11 @@
"avatarUpdated": "Avatar aktualisiert", "avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt", "avatarRemoved": "Avatar entfernt",
"loginWithSSO": "Mit {{provider}} anmelden", "loginWithSSO": "Mit {{provider}} anmelden",
"or": "oder" "or": "oder",
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
}, },
"common": { "common": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -410,7 +436,7 @@
"importSuccess": "Daten erfolgreich importiert", "importSuccess": "Daten erfolgreich importiert",
"importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links", "importSuccessDetails": "Importiert: {{medications}} Medikamente, {{doses}} Dosen, {{shares}} Teilen-Links",
"importError": "Daten konnten nicht importiert werden", "importError": "Daten konnten nicht importiert werden",
"invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-Exportdatei.", "invalidFile": "Ungültiges Dateiformat. Bitte wähle eine gültige MedAssist-ng-Exportdatei.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
}, },
"refill": { "refill": {
+37 -13
View File
@@ -17,12 +17,15 @@
}, },
"dashboard": { "dashboard": {
"reorder": { "reorder": {
"title": "Reorder Reminder", "title": "Refill Reminder",
"badge": "Stock watch", "badge": "Stock watch",
"noMeds": "No medications configured yet.", "noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.", "allGood": "All good, enough stock.",
"lowWarning": "Enough stock for now, but {{count}} medication is running low.", "lowWarning": "Enough stock for now, but {{meds}} is running low.",
"lowWarning_other": "Enough stock for now, but {{count}} medications are running low.", "lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
"lowWarningPrefix": "Enough stock for now, but",
"lowWarningSuffix": "is running low.",
"lowWarningSuffix_other": "are running low.",
"sendReminder": "🔔 Send Reminder Now" "sendReminder": "🔔 Send Reminder Now"
}, },
"overview": { "overview": {
@@ -66,8 +69,8 @@
"inDays_one": "in {{days}} day", "inDays_one": "in {{days}} day",
"inDays_other": "in {{days}} days", "inDays_other": "in {{days}} days",
"noRemindersNeeded": "No reminders needed", "noRemindersNeeded": "No reminders needed",
"needReorder": "{{count}} med needs reorder", "needRefill": "{{count}} med needs refill",
"needReorder_other": "{{count}} meds need reorder", "needRefill_other": "{{count}} meds need refill",
"emptyStock": "{{count}} med is empty", "emptyStock": "{{count}} med is empty",
"emptyStock_other": "{{count}} meds are empty", "emptyStock_other": "{{count}} meds are empty",
"lowWarning": "{{count}} medication running low", "lowWarning": "{{count}} medication running low",
@@ -83,7 +86,10 @@
"criticalMeds": "{{count}} medication critical", "criticalMeds": "{{count}} medication critical",
"criticalMeds_other": "{{count}} medications critical", "criticalMeds_other": "{{count}} medications critical",
"lowMeds": "{{count}} medication low", "lowMeds": "{{count}} medication low",
"lowMeds_other": "{{count}} medications low" "lowMeds_other": "{{count}} medications low",
"daysLeft": "{{days}} day left",
"daysLeft_other": "{{days}} days left",
"needsRefill": "Needs refill"
} }
}, },
"table": { "table": {
@@ -93,11 +99,16 @@
"currentPills": "Current pills", "currentPills": "Current pills",
"fullBlisters": "Full blisters", "fullBlisters": "Full blisters",
"openBlister": "Open blister", "openBlister": "Open blister",
"stock": "Stock",
"stockDetails": "Details",
"daysLeft": "Days left", "daysLeft": "Days left",
"status": "Stock", "status": "Status",
"runsOut": "Runs out", "runsOut": "Runs out",
"autoRemind": "Auto-remind", "autoRemind": "Auto-remind",
"expiry": "Expiry" "expiry": "Expiry",
"pillsCount": "{{count}} pills",
"pillsCount_one": "{{count}} pill",
"pillsCount_other": "{{count}} pills"
}, },
"medications": { "medications": {
"list": { "list": {
@@ -111,7 +122,8 @@
"blisters": "Blisters per pack", "blisters": "Blisters per pack",
"pillsPerBlister": "Pills per blister", "pillsPerBlister": "Pills per blister",
"loose": "Loose", "loose": "Loose",
"total": "Total" "total": "Total",
"stock": "Stock"
} }
}, },
"form": { "form": {
@@ -121,11 +133,16 @@
"commercialName": "Commercial Name", "commercialName": "Commercial Name",
"genericName": "Generic Name", "genericName": "Generic Name",
"takenBy": "Taken by", "takenBy": "Taken by",
"packageType": "Package Type",
"packageTypeBlister": "Blister Pack",
"packageTypeBottle": "Pill Bottle / Container",
"packs": "Packs", "packs": "Packs",
"blistersPerPack": "Blisters per pack", "blistersPerPack": "Blisters per pack",
"pillsPerBlister": "Pills per blister", "pillsPerBlister": "Pills per blister",
"totalCapacity": "Total Capacity",
"currentPills": "Current Pills",
"loosePills": "Loose pills", "loosePills": "Loose pills",
"pillWeight": "Pill weight (mg)", "pillWeight": "Dose per pill",
"total": "Total (pills)", "total": "Total (pills)",
"expiryDate": "Expiry Date", "expiryDate": "Expiry Date",
"notes": "Notes", "notes": "Notes",
@@ -149,7 +166,9 @@
"every": "every", "every": "every",
"from": "from", "from": "from",
"startDate": "Date", "startDate": "Date",
"startTime": "Time" "startTime": "Time",
"takenByIntake": "Taken by",
"takenByEveryone": "Everyone"
} }
}, },
"planner": { "planner": {
@@ -255,6 +274,7 @@
}, },
"status": { "status": {
"outOfStock": "Empty", "outOfStock": "Empty",
"criticalStock": "Critical",
"lowStock": "Low", "lowStock": "Low",
"normal": "Normal", "normal": "Normal",
"highStock": "High", "highStock": "High",
@@ -311,7 +331,11 @@
"avatarUpdated": "Avatar updated", "avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed", "avatarRemoved": "Avatar removed",
"loginWithSSO": "Login with {{provider}}", "loginWithSSO": "Login with {{provider}}",
"or": "or" "or": "or",
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
}, },
"common": { "common": {
"loading": "Loading...", "loading": "Loading...",
@@ -412,7 +436,7 @@
"importSuccess": "Data imported successfully", "importSuccess": "Data imported successfully",
"importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links", "importSuccessDetails": "Imported: {{medications}} medications, {{doses}} doses, {{shares}} share links",
"importError": "Failed to import data", "importError": "Failed to import data",
"invalidFile": "Invalid file format. Please select a valid MedAssist export file.", "invalidFile": "Invalid file format. Please select a valid MedAssist-ng export file.",
"downloadFilename": "medassist-export" "downloadFilename": "medassist-export"
}, },
"refill": { "refill": {
+171 -254
View File
@@ -1,28 +1,16 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components"; import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
import type { Coverage } from "../types"; import type { Coverage } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters"; import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { getStockStatus } from "../utils/schedule";
// Helper for user-specific localStorage keys // Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key; return userId ? `user_${userId}_${key}` : key;
} }
// Helper function to get stock status
function getStockStatus(
daysLeft: number | null,
medsLeft: number,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number }
) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" };
return { className: "success", label: "status.normal" };
}
// Helper function to calculate blister stock // Helper function to calculate blister stock
function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) { function getBlisterStock(totalPills: number, pillsPerBlister: number, _looseTablets: number, _originalTotal: number) {
const fullBlisters = Math.floor(totalPills / pillsPerBlister); const fullBlisters = Math.floor(totalPills / pillsPerBlister);
@@ -57,19 +45,6 @@ function getMedTotal(med: {
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
} }
// Get next reminder date for a medication
function getNextReminderForMed(row: Coverage, reminderDaysBefore: number, locale: string): string {
if (!row.depletionDate) return "-";
const depletionDate = new Date(row.depletionDate);
const reminderDate = new Date(depletionDate);
reminderDate.setDate(reminderDate.getDate() - reminderDaysBefore);
const now = new Date();
if (reminderDate <= now) return "-";
return reminderDate.toLocaleDateString(locale, { day: "2-digit", month: "short" });
}
// Notification bell SVG icon (no emoji) // Notification bell SVG icon (no emoji)
function NotificationBellIcon() { function NotificationBellIcon() {
return ( return (
@@ -105,14 +80,14 @@ function getReminderStatusData(
locale: string locale: string
): { ): {
status: { text: string; className: string }; status: { text: string; className: string };
next: { name: string; days: number } | null; lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
lastSent: { date: string; medName: string | null; takenBy: string | null } | null; lastSent: { date: string; medName: string | null; takenBy: string | null } | null;
} { } {
const criticalCount = lowCoverage.length; const criticalCount = lowCoverage.length;
const lowCount = allCoverage.filter((c) => { const lowCount = allCoverage.filter((c) => {
if (c.medsLeft <= 0) return false; if (c.medsLeft <= 0) return false;
if (c.daysLeft === null) return false; if (c.daysLeft === null) return false;
return c.daysLeft < lowStockDays && c.daysLeft > 3; return c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore;
}).length; }).length;
// Determine status // Determine status
@@ -134,17 +109,34 @@ function getReminderStatusData(
}; };
} }
// Find next medication to hit reminder threshold // Collect all low stock medications (critical + low), deduplicated by name
const nextToRunOut = allCoverage const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
.filter((c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
let next: { name: string; days: number } | null = null; // Add critical meds (from lowCoverage - these are ≤3 days)
if (nextToRunOut && nextToRunOut.daysLeft !== null) { for (const c of lowCoverage) {
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore); if (c.daysLeft !== null) {
next = { name: nextToRunOut.name, days: daysUntilReminder }; const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
}
}
} }
// Add low but not critical meds
for (const c of allCoverage) {
if (c.medsLeft <= 0) continue;
if (c.daysLeft === null) continue;
if (c.daysLeft < lowStockDays && c.daysLeft > reminderDaysBefore) {
const existing = lowStockMap.get(c.name);
if (!existing || c.daysLeft < existing.daysLeft) {
lowStockMap.set(c.name, { name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
}
}
}
// 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 // Parse last sent info
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null; let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
if (lastAutoEmailSent) { if (lastAutoEmailSent) {
@@ -163,7 +155,7 @@ function getReminderStatusData(
}; };
} }
return { status, next, lastSent }; return { status, lowStockMeds, lastSent };
} }
export function DashboardPage() { export function DashboardPage() {
@@ -202,38 +194,9 @@ export function DashboardPage() {
openUserFilter, openUserFilter,
openShareDialog, openShareDialog,
openScheduleLightbox, openScheduleLightbox,
stockThresholds,
} = useAppContext(); } = useAppContext();
// Local state for reminder email
const [sendingReminderEmail, setSendingReminderEmail] = useState(false);
const [reminderEmailResult, setReminderEmailResult] = useState<{ success: boolean; message: string } | null>(null);
async function sendReminderEmail() {
if (!settings.notificationEmail || coverage.low.length === 0) return;
setSendingReminderEmail(true);
setReminderEmailResult(null);
try {
const res = await fetch("/api/reminder/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: settings.notificationEmail,
lowStock: coverage.low,
}),
});
const data = await res.json();
if (res.ok) {
setReminderEmailResult({ success: true, message: data.message || "Email sent!" });
} else {
setReminderEmailResult({ success: false, message: data.error || "Failed to send" });
}
} catch {
setReminderEmailResult({ success: false, message: "Network error" });
}
setSendingReminderEmail(false);
}
// Get structured reminder data // Get structured reminder data
const reminderData = getReminderStatusData( const reminderData = getReminderStatusData(
settings.reminderDaysBefore, settings.reminderDaysBefore,
@@ -267,172 +230,130 @@ export function DashboardPage() {
<NotificationBellIcon /> <NotificationBellIcon />
</span> </span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span> <span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
<span className={`reminder-status-badge ${reminderData.status.className}`}> {reminderData.lowStockMeds.length === 0 && (
{reminderData.status.className === "success" && "✓ "} <span className={`reminder-status-badge ${reminderData.status.className}`}>
{reminderData.status.text} {reminderData.status.className === "success" && "✓ "}
</span> {reminderData.status.text}
</div> </span>
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.next && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.next")}:</span>
<span className="reminder-status-value">
{reminderData.next.name}{" "}
{t("dashboard.reminders.inDays", { count: reminderData.next.days, days: reminderData.next.days })}
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName && (
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
)}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
)}
<span className="reminder-date">{reminderData.lastSent.date}</span>
</span>
</div>
)} )}
</div> </div>
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{reminderData.lowStockMeds.map((med, idx) => {
const medication = meds.find((m) => m.name === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
const textClass =
status?.className === "danger"
? "danger-text"
: status?.className === "warning"
? "warning-text"
: "";
return (
<span key={med.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && openMedDetail(medication)}
>
{med.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</span>
);
})}
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastSent.medName && (
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
)}
{reminderData.lastSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastSent.date}</span>
</span>
</div>
)}
</div>
)}
</section> </section>
)} )}
<section className="grid"> {/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
<article className="card"> {!anyRemindersEnabled && (
<div className="card-head"> <section className="grid">
<h2>{t("dashboard.reorder.title")}</h2> <article className="card">
</div> <div className="card-head">
{(() => { <h2>{t("dashboard.reorder.title")}</h2>
if (meds.length === 0) { </div>
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>; {(() => {
} if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
// Count medications with "Low" stock status (based on lowStockDays setting) // Count medications with low stock (based on lowStockDays setting), deduplicated by name
const lowStockCount = coverage.all.filter((c) => { const lowStockMap = new Map<string, Coverage>();
if (c.medsLeft <= 0) return true; // out of stock for (const c of coverage.all) {
if (c.daysLeft === null) return false; // no schedule if (c.daysLeft === null && c.medsLeft > 0) continue; // no schedule, has stock
return c.daysLeft < settings.lowStockDays; if (c.medsLeft <= 0 || c.daysLeft === null || c.daysLeft < settings.lowStockDays) {
}).length; const existing = lowStockMap.get(c.name);
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
lowStockMap.set(c.name, c);
}
}
}
const lowStockMeds = Array.from(lowStockMap.values());
const lowStockCount = lowStockMeds.length;
if (coverage.low.length === 0) {
// No critical meds (≤3 days)
if (lowStockCount === 0) { if (lowStockCount === 0) {
// All good - everything is Normal or High // All good - everything is Normal or High
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>; return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
} else {
// Some meds are Low but not critical
return <p className="warning-text">{t("dashboard.reorder.lowWarning", { count: lowStockCount })}</p>;
} }
}
return ( // Some meds are low - show simple text with clickable names and days left
<> return (
<div className="table table-7"> <p>
<div className="table-head"> {t("dashboard.reorder.lowWarningPrefix")}{" "}
<span>{t("table.name")}</span> {lowStockMeds.map((c, idx) => {
<span>{t("table.fullBlisters")}</span> const med = meds.find((m) => m.name === c.name);
<span>{t("table.openBlister")}</span> const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
<span>{t("table.daysLeft")}</span>
<span>{t("table.status")}</span>
<span>{t("table.runsOut")}</span>
<span>{t("table.autoRemind")}</span>
</div>
{coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find((m) => m.name === row.name);
const textClass = const textClass =
status.className === "danger" status.className === "danger"
? "danger-text" ? "danger-text"
: status.className === "warning" : status.className === "warning"
? "warning-text" ? "warning-text"
: "success-text"; : "";
const stock = getBlisterStock(
Math.round(row.medsLeft),
med?.pillsPerBlister ?? 1,
med?.looseTablets ?? 0,
med ? getMedTotal(med) : Math.round(row.medsLeft)
);
return ( return (
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}> <span key={c.name}>
<span data-label={t("table.name")} className="cell-with-avatar"> {idx > 0 && ", "}
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} /> <span className={`med-link clickable ${textClass}`} onClick={() => med && openMedDetail(med)}>
<span className="med-name-text">{row.name}</span> {c.name}
{med?.takenBy &&
med.takenBy.length > 0 &&
med.takenBy.map((person) => (
<span
key={person}
className="taken-by-badge clickable"
onClick={(e) => {
e.stopPropagation();
openUserFilter(person);
}}
>
{person}
</span>
))}
{(med?.intakeRemindersEnabled || med?.notes) && (
<span className="med-icons">
{med?.intakeRemindersEnabled && (
<span
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
</span>
)}
{med?.notes && (
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
📝
</span>
)}
</span>
)}
</span> </span>
<span data-label={t("table.fullBlisters")} className={textClass}> <span className={`reminder-days-left ${textClass}`}>
{formatFullBlisters(stock.fullBlisters, t)} {" "}
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
</span> </span>
<span data-label={t("table.openBlister")} className={textClass}>
{formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span>
<span data-label={t("table.days")} className={textClass}>
{formatNumber(row.daysLeft)}
</span>
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
{t(status.label)}
</span>
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
<span data-label={t("table.autoRemind")} className="next-reminder-date">
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
</span>
</div>
);
})}
</div>
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
<div className="email-send-action">
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderEmailResult && (
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
{reminderEmailResult.message}
</span> </span>
)} );
</div> })}{" "}
)} {t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</> </p>
); );
})()} })()}
</article> </article>
</section> </section>
)}
<section className="grid"> <section className="grid">
<article className="card"> <article className="card">
@@ -442,15 +363,15 @@ export function DashboardPage() {
<div className="table table-7"> <div className="table table-7">
<div className="table-head"> <div className="table-head">
<span>{t("table.name")}</span> <span>{t("table.name")}</span>
<span>{t("table.fullBlisters")}</span> <span>{t("table.stock")}</span>
<span>{t("table.openBlister")}</span> <span>{t("table.stockDetails")}</span>
<span>{t("table.daysLeft")}</span> <span>{t("table.daysLeft")}</span>
<span>{t("table.runsOut")}</span> <span>{t("table.runsOut")}</span>
<span>{t("table.expiry")}</span> <span>{t("table.expiry")}</span>
<span>{t("table.status")}</span> <span>{t("table.status")}</span>
</div> </div>
{coverage.all.map((row) => { {coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
const med = meds.find((m) => m.name === row.name); const med = meds.find((m) => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass = const textClass =
@@ -501,11 +422,20 @@ export function DashboardPage() {
</span> </span>
)} )}
</span> </span>
<span data-label={t("table.fullBlisters")} className={textClass}> <span data-label={t("table.stock")} className={textClass}>
{formatFullBlisters(stock.fullBlisters, t)} {med?.packageType === "bottle"
? t("table.pillsCount", { count: Math.round(row.medsLeft) })
: formatFullBlisters(stock.fullBlisters, t)}
</span> </span>
<span data-label={t("table.openBlister")} className={textClass}> <span data-label={t("table.stockDetails")} className={textClass}>
{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.pillsPerBlister ?? 1, t)} {med?.packageType === "bottle"
? "-"
: formatOpenBlisterAndLoose(
stock.openBlisterPills,
stock.loosePills,
med?.pillsPerBlister ?? 1,
t
)}
</span> </span>
<span data-label={t("table.daysLeft")} className={textClass}> <span data-label={t("table.daysLeft")} className={textClass}>
{formatNumber(row.daysLeft)} {formatNumber(row.daysLeft)}
@@ -562,9 +492,7 @@ export function DashboardPage() {
const missedCount = missedPastDoseIds.length; const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => const totalPastDoses = pastDays.flatMap((d) =>
d.meds.flatMap((m) => d.meds.flatMap((m) =>
m.doses.flatMap((dose) => m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
) )
); );
return ( return (
@@ -613,9 +541,7 @@ export function DashboardPage() {
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
)
); );
const allDayTaken = const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id)); allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
@@ -660,9 +586,7 @@ export function DashboardPage() {
const med = meds.find((m) => m.name === item.medName); const med = meds.find((m) => m.name === item.medName);
const medCov = coverageByMed[item.medName]; const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false; const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -693,13 +617,14 @@ export function DashboardPage() {
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
// If no takenBy, show single checkbox; otherwise show one per person // If no takenBy, show single checkbox; otherwise show one per person
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const people = dose.takenBy ? [dose.takenBy] : [null];
return ( return (
<div key={dose.id} className="dose-item past"> <div key={dose.id} className="dose-item past">
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { {people.map((person) => {
@@ -752,9 +677,7 @@ export function DashboardPage() {
(() => { (() => {
const day = todayDay; const day = todayDay;
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
)
); );
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -765,7 +688,7 @@ export function DashboardPage() {
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger"; if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success"; if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className; return status.className;
}); });
const worstStatus = dayStockStatuses.includes("danger") const worstStatus = dayStockStatuses.includes("danger")
@@ -812,11 +735,9 @@ export function DashboardPage() {
const status = willBeOutOfStock const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" } ? { className: "danger", label: "status.outOfStock" }
: medCoverage : medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null; : null;
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -848,7 +769,7 @@ export function DashboardPage() {
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const isOverdue = dose.when < Date.now(); const isOverdue = dose.when < Date.now();
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const people = dose.takenBy ? [dose.takenBy] : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return ( return (
<div <div
@@ -858,7 +779,8 @@ export function DashboardPage() {
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { {people.map((person) => {
@@ -911,9 +833,7 @@ export function DashboardPage() {
(() => { (() => {
const totalFutureDoses = futureDays.flatMap((d) => const totalFutureDoses = futureDays.flatMap((d) =>
d.meds.flatMap((m) => d.meds.flatMap((m) =>
m.doses.flatMap((dose) => m.doses.flatMap((dose) => (dose.takenBy ? [`${dose.id}-${dose.takenBy}`] : [dose.id]))
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
) )
); );
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length; const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
@@ -945,9 +865,7 @@ export function DashboardPage() {
{showFutureDays && {showFutureDays &&
futureDays.map((day) => { futureDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]))
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
)
); );
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -958,7 +876,7 @@ export function DashboardPage() {
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
if (willBeOutOfStock) return "danger"; if (willBeOutOfStock) return "danger";
if (!medCoverage) return "success"; if (!medCoverage) return "success";
const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds);
return status.className; return status.className;
}); });
const worstStatus = dayStockStatuses.includes("danger") const worstStatus = dayStockStatuses.includes("danger")
@@ -1004,11 +922,9 @@ export function DashboardPage() {
const status = willBeOutOfStock const status = willBeOutOfStock
? { className: "danger", label: "status.outOfStock" } ? { className: "danger", label: "status.outOfStock" }
: medCoverage : medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, stockThresholds)
: null; : null;
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.flatMap((d) => (d.takenBy ? [`${d.id}-${d.takenBy}`] : [d.id]));
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]
);
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -1039,14 +955,15 @@ export function DashboardPage() {
</div> </div>
<div className="doses-col"> <div className="doses-col">
{item.doses.map((dose) => { {item.doses.map((dose) => {
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null]; const people = dose.takenBy ? [dose.takenBy] : [null];
const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person))); const allTaken = people.every((person) => takenDoses.has(getDoseId(dose.id, person)));
return ( return (
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}> <div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg &&
` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { {people.map((person) => {
+176 -74
View File
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components"; import { ConfirmModal, MedicationAvatar, MobileEditModal } from "../components";
import { useAppContext, useUnsavedChanges } from "../context"; import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks"; import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
import type { Medication } from "../types"; import type { DoseUnit, Medication } from "../types";
import { FIELD_LIMITS, getPackageSize } from "../types"; import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters"; import { combineDateAndTime, formatDateTime, formatNumber } from "../utils/formatters";
export function MedicationsPage() { export function MedicationsPage() {
@@ -25,6 +25,7 @@ export function MedicationsPage() {
setRefillLoose, setRefillLoose,
refillSaving, refillSaving,
submitRefill, submitRefill,
coverageByMed,
} = useAppContext(); } = useAppContext();
// Use the medication form hook // Use the medication form hook
@@ -47,6 +48,9 @@ export function MedicationsPage() {
addBlister, addBlister,
removeBlister, removeBlister,
setBlisterValue, setBlisterValue,
addIntake,
removeIntake,
setIntakeValue,
resetForm, resetForm,
startEdit, startEdit,
} = useMedicationForm(); } = useMedicationForm();
@@ -87,12 +91,17 @@ export function MedicationsPage() {
// Calculate total tablets // Calculate total tablets
const totalTablets = useMemo(() => { const totalTablets = useMemo(() => {
if (form.packageType === "bottle") {
// For bottle type, looseTablets is the current stock
return Number(form.looseTablets) || 0;
}
// For blister type
const packCount = Number(form.packCount) || 0; const packCount = Number(form.packCount) || 0;
const blistersPerPack = Number(form.blistersPerPack) || 0; const blistersPerPack = Number(form.blistersPerPack) || 0;
const pillsPerBlister = Number(form.pillsPerBlister) || 1; const pillsPerBlister = Number(form.pillsPerBlister) || 1;
const looseTablets = Number(form.looseTablets) || 0; const looseTablets = Number(form.looseTablets) || 0;
return packCount * blistersPerPack * pillsPerBlister + looseTablets; return packCount * blistersPerPack * pillsPerBlister + looseTablets;
}, [form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]); }, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
// Open mobile edit modal // Open mobile edit modal
function openEditModal() { function openEditModal() {
@@ -158,26 +167,39 @@ export function MedicationsPage() {
if (saving) return; if (saving) return;
setSaving(true); setSaving(true);
// Prepare medication data // Prepare intakes data with per-intake takenBy
const blisters = form.blisters.map((b) => ({ const intakes = form.intakes.map((intake) => ({
usage: Number(b.usage) || 1, usage: Number(intake.usage) || 1,
every: Number(b.every) || 1, every: Number(intake.every) || 1,
start: combineDateAndTime(b.startDate, b.startTime), start: combineDateAndTime(intake.startDate, intake.startTime),
takenBy: intake.takenBy.trim() || null, // Empty string becomes null
intakeRemindersEnabled: intake.intakeRemindersEnabled,
}));
// Also prepare legacy blisters for backward compatibility
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
})); }));
const body = { const body = {
name: form.name.trim(), name: form.name.trim(),
genericName: form.genericName.trim() || null, genericName: form.genericName.trim() || null,
takenBy: form.takenBy.length > 0 ? form.takenBy : [], takenBy: form.takenBy.length > 0 ? form.takenBy : [],
packageType: form.packageType,
packCount: Number(form.packCount) || 0, packCount: Number(form.packCount) || 0,
blistersPerPack: Number(form.blistersPerPack) || 1, blistersPerPack: Number(form.blistersPerPack) || 1,
pillsPerBlister: Number(form.pillsPerBlister) || 1, pillsPerBlister: Number(form.pillsPerBlister) || 1,
totalPills: Number(form.totalPills) || null,
looseTablets: Number(form.looseTablets) || 0, looseTablets: Number(form.looseTablets) || 0,
pillWeightMg: Number(form.pillWeightMg) || null, pillWeightMg: Number(form.pillWeightMg) || null,
doseUnit: form.doseUnit,
expiryDate: form.expiryDate || null, expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null, notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled, intakeRemindersEnabled: form.intakeRemindersEnabled,
blisters, blisters, // Legacy format for backward compatibility
intakes, // New format with per-intake takenBy
}; };
try { try {
@@ -192,6 +214,7 @@ export function MedicationsPage() {
method, method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
@@ -308,7 +331,7 @@ export function MedicationsPage() {
</div> </div>
<div className="med-list"> <div className="med-list">
{meds.map((med) => ( {meds.map((med) => (
<div key={med.id} className="med-row"> <div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
<div className="med-header"> <div className="med-header">
<div className="med-info"> <div className="med-info">
<div className="med-name-row"> <div className="med-name-row">
@@ -330,7 +353,9 @@ export function MedicationsPage() {
</span> </span>
</div> </div>
<div className="med-total"> <div className="med-total">
{t("medications.details.total")}: {getPackageSize(med)} {t("common.pills")} {t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {t("common.pills")}
</div> </div>
</div> </div>
<div className="med-actions"> <div className="med-actions">
@@ -358,7 +383,20 @@ export function MedicationsPage() {
<article className="card form desktop-only"> <article className="card form desktop-only">
<div className="card-head"> <div className="card-head">
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2> {editingId ? (
<div className="edit-header">
<MedicationAvatar
name={meds.find((m) => m.id === editingId)?.name || ""}
imageUrl={meds.find((m) => m.id === editingId)?.imageUrl}
size="md"
/>
<h2>
{t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name}
</h2>
</div>
) : (
<h2>{t("form.newEntry")}</h2>
)}
</div> </div>
<form className="form-grid" onSubmit={saveMedication}> <form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? "has-error" : ""}> <label className={fieldErrors.name ? "has-error" : ""}>
@@ -417,50 +455,100 @@ export function MedicationsPage() {
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>} {fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
</label> </label>
<label> <label>
{t("form.packs")} {t("form.packageType")}
<input <select
type="number" className="package-type-select"
min="0" value={form.packageType}
value={form.packCount} onChange={(e) => handleValueChange("packageType", e.target.value)}
onChange={(e) => handleValueChange("packCount", e.target.value)} >
/> <option value="blister">{t("form.packageTypeBlister")}</option>
<option value="bottle">{t("form.packageTypeBottle")}</option>
</select>
</label> </label>
{form.packageType === "blister" ? (
<>
<label>
{t("form.packs")}
<input
type="number"
min="0"
value={form.packCount}
onChange={(e) => handleValueChange("packCount", e.target.value)}
/>
</label>
<label>
{t("form.blistersPerPack")}
<input
type="number"
min="1"
value={form.blistersPerPack}
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)}
/>
</label>
<label>
{t("form.pillsPerBlister")}
<input
type="number"
min="1"
value={form.pillsPerBlister}
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)}
/>
</label>
<label>
{t("form.loosePills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
) : (
<>
<label>
{t("form.totalCapacity")}
<input
type="number"
min="1"
value={form.totalPills}
onChange={(e) => handleValueChange("totalPills", e.target.value)}
/>
</label>
<label>
{t("form.currentPills")}
<input
type="number"
min="0"
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
</>
)}
<label> <label>
{t("form.blistersPerPack")} {t("form.pillWeight")} ({form.doseUnit})
<input <div className="dose-input-group">
type="number" <input
min="1" type="number"
value={form.blistersPerPack} min="0"
onChange={(e) => handleValueChange("blistersPerPack", e.target.value)} step="0.1"
/> value={form.pillWeightMg}
</label> onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
<label> placeholder={t("form.placeholders.weight")}
{t("form.pillsPerBlister")} />
<input <select
type="number" value={form.doseUnit}
min="1" onChange={(e) => handleValueChange("doseUnit", e.target.value as DoseUnit)}
value={form.pillsPerBlister} className="dose-unit-select"
onChange={(e) => handleValueChange("pillsPerBlister", e.target.value)} >
/> {DOSE_UNITS.map((unit) => (
</label> <option key={unit.value} value={unit.value}>
<label> {unit.label}
{t("form.loosePills")} </option>
<input ))}
type="number" </select>
min="0" </div>
value={form.looseTablets}
onChange={(e) => handleValueChange("looseTablets", e.target.value)}
/>
</label>
<label>
{t("form.pillWeight")}
<input
type="number"
min="1"
value={form.pillWeightMg}
onChange={(e) => handleValueChange("pillWeightMg", e.target.value)}
placeholder={t("form.placeholders.weight")}
/>
</label> </label>
<label> <label>
{t("form.total")} {t("form.total")}
@@ -544,20 +632,12 @@ export function MedicationsPage() {
<div className="card-head"> <div className="card-head">
<h3>{t("form.blisters.title")}</h3> <h3>{t("form.blisters.title")}</h3>
<div className="blisters-actions"> <div className="blisters-actions">
<label className="inline-checkbox" title={t("form.blisters.remindTooltip")}> <button type="button" className="primary" onClick={() => addIntake()}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm((prev) => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t("form.blisters.remind")}</span>
</label>
<button type="button" className="primary" onClick={addBlister}>
+ {t("form.blisters.addIntake")} + {t("form.blisters.addIntake")}
</button> </button>
</div> </div>
</div> </div>
{form.blisters.map((s, idx) => ( {form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row"> <div key={idx} className="blister-row">
<div className="blister-inputs"> <div className="blister-inputs">
<label> <label>
@@ -566,8 +646,8 @@ export function MedicationsPage() {
type="number" type="number"
min="0" min="0"
step="0.1" step="0.1"
value={s.usage} value={intake.usage}
onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
/> />
</label> </label>
<label> <label>
@@ -575,29 +655,48 @@ export function MedicationsPage() {
<input <input
type="number" type="number"
min="1" min="1"
value={s.every} value={intake.every}
onChange={(e) => setBlisterValue(idx, "every", e.target.value)} onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
/> />
</label> </label>
<label> <label>
{t("form.blisters.startDate")} {t("form.blisters.startDate")}
<input <input
type="date" type="date"
value={s.startDate} value={intake.startDate}
onChange={(e) => setBlisterValue(idx, "startDate", e.target.value)} onChange={(e) => setIntakeValue(idx, "startDate", e.target.value)}
/> />
</label> </label>
<label> <label>
{t("form.blisters.startTime")} {t("form.blisters.startTime")}
<input <input
type="time" type="time"
value={s.startTime} value={intake.startTime}
onChange={(e) => setBlisterValue(idx, "startTime", e.target.value)} onChange={(e) => setIntakeValue(idx, "startTime", e.target.value)}
/> />
</label> </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)}
/>
<span>🔔</span>
</label>
</div> </div>
{form.blisters.length > 1 && ( {form.intakes.length > 1 && (
<button type="button" className="danger" onClick={() => removeBlister(idx)}> <button type="button" className="danger" onClick={() => removeIntake(idx)}>
{t("common.remove")} {t("common.remove")}
</button> </button>
)} )}
@@ -702,6 +801,9 @@ export function MedicationsPage() {
onSetBlisterValue={setBlisterValue} onSetBlisterValue={setBlisterValue}
onAddBlister={addBlister} onAddBlister={addBlister}
onRemoveBlister={removeBlister} onRemoveBlister={removeBlister}
onSetIntakeValue={setIntakeValue}
onAddIntake={addIntake}
onRemoveIntake={removeIntake}
onHandleValueChange={handleValueChange} onHandleValueChange={handleValueChange}
refillPacks={refillPacks} refillPacks={refillPacks}
onRefillPacksChange={setRefillPacks} onRefillPacksChange={setRefillPacks}
+2
View File
@@ -82,6 +82,7 @@ export function PlannerPage() {
const rows = (await fetch("/api/medications/usage", { const rows = (await fetch("/api/medications/usage", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
.then((res) => res.json()) .then((res) => res.json())
@@ -113,6 +114,7 @@ export function PlannerPage() {
const res = await fetch("/api/planner/send-email", { const res = await fetch("/api/planner/send-email", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
email: settings.notificationEmail, email: settings.notificationEmail,
from: range.start, from: range.start,
+35 -29
View File
@@ -9,15 +9,23 @@ function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key; return userId ? `user_${userId}_${key}` : key;
} }
// Helper function to get stock status // Helper function to get stock status based on thresholds
function getStockStatus( function getStockStatus(
daysLeft: number | null, daysLeft: number | null,
medsLeft: number, medsLeft: number,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number } settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
) { ) {
if (medsLeft <= 0 || daysLeft === null || daysLeft <= 0) return { className: "danger", label: "status.outOfStock" }; // Out of stock or completely depleted = danger (red)
if (daysLeft <= settings.lowStockDays) return { className: "danger", label: "status.lowStock" }; if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
if (daysLeft >= settings.highStockDays) return { className: "success", label: "status.highStock" }; // No schedule, but has stock = normal
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
// Critical: at or below reminder threshold = danger (red)
if (daysLeft <= settings.reminderDaysBefore) return { className: "danger", label: "status.criticalStock" };
// Low: below low stock threshold = warning (yellow)
if (daysLeft < settings.lowStockDays) return { className: "warning", label: "status.lowStock" };
// High stock
if (daysLeft >= settings.highStockDays) return { className: "high", label: "status.highStock" };
// Normal stock
return { className: "success", label: "status.normal" }; return { className: "success", label: "status.normal" };
} }
@@ -25,7 +33,7 @@ function getStockStatus(
function getDayStockStatus( function getDayStockStatus(
dayMeds: Array<{ medName: string }>, dayMeds: Array<{ medName: string }>,
coverageByMed: Record<string, Coverage>, coverageByMed: Record<string, Coverage>,
settings: { lowStockDays: number; normalStockDays: number; highStockDays: number } settings: { lowStockDays: number; normalStockDays: number; highStockDays: number; reminderDaysBefore: number }
): string { ): string {
let worstLevel = 3; // 3=success, 2=warning, 1=danger let worstLevel = 3; // 3=success, 2=warning, 1=danger
for (const item of dayMeds) { for (const item of dayMeds) {
@@ -63,6 +71,7 @@ export function SchedulePage() {
manuallyExpandedDays, manuallyExpandedDays,
toggleDayCollapse, toggleDayCollapse,
openUserFilter, openUserFilter,
missedPastDoseIds,
} = useAppContext(); } = useAppContext();
return ( return (
@@ -88,17 +97,11 @@ export function SchedulePage() {
{/* Past days toggle */} {/* Past days toggle */}
{pastDays.length > 0 && {pastDays.length > 0 &&
(() => { (() => {
const totalPastDoses = pastDays.flatMap((d) => // Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
d.meds.flatMap((m) => const missedCount = missedPastDoseIds.length;
m.doses.flatMap((dose) =>
(dose.takenBy || []).length > 0 ? dose.takenBy.map((p) => `${dose.id}-${p}`) : [dose.id]
)
)
);
const missedPastDoses = totalPastDoses.filter((id) => !takenDoses.has(id)).length;
return ( return (
<div <div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedPastDoses > 0 ? "has-missed" : ""}`} className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)} onClick={() => setShowPastDays(!showPastDays)}
> >
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span> <span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
@@ -108,12 +111,12 @@ export function SchedulePage() {
<span className="past-days-count"> <span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })}) ({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span> </span>
{missedPastDoses > 0 && ( {missedCount > 0 && (
<span <span
className="past-days-warning" className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedPastDoses })} title={t("dashboard.schedules.missedDoses", { count: missedCount })}
> >
{missedPastDoses} {missedCount}
</span> </span>
)} )}
</div> </div>
@@ -123,9 +126,10 @@ export function SchedulePage() {
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => item.doses.flatMap((d) => {
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
) return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
})
); );
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
@@ -168,9 +172,10 @@ export function SchedulePage() {
const med = meds.find((m) => m.name === item.medName); const med = meds.find((m) => m.name === item.medName);
const medCov = coverageByMed[item.medName]; const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false; const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.flatMap((d) => {
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
); return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
});
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -202,7 +207,7 @@ export function SchedulePage() {
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { {people.map((person) => {
@@ -272,9 +277,10 @@ export function SchedulePage() {
: medCoverage : medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
: null; : null;
const itemDoseIds = item.doses.flatMap((d) => const itemDoseIds = item.doses.flatMap((d) => {
(d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id] const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
); return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
});
const allTaken = itemDoseIds.every((id) => takenDoses.has(id)); const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -306,7 +312,7 @@ export function SchedulePage() {
<span className="dose-time">{dose.timeStr}</span> <span className="dose-time">{dose.timeStr}</span>
<span className="dose-usage"> <span className="dose-usage">
{dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")} {dose.usage} {dose.usage !== 1 ? t("common.pills") : t("common.pill")}
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}
</span> </span>
<div className="dose-checks"> <div className="dose-checks">
{people.map((person) => { {people.map((person) => {
+174 -10
View File
@@ -195,7 +195,7 @@ body.modal-open {
.reminder-status-header { .reminder-status-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -212,11 +212,10 @@ body.modal-open {
} }
.reminder-status-badge { .reminder-status-badge {
padding: 0.2rem 0.5rem; padding: 0.25rem 0.6rem;
border-radius: 4px; border-radius: 6px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
margin-left: auto;
} }
.reminder-status-badge.success { .reminder-status-badge.success {
@@ -282,6 +281,31 @@ body.modal-open {
color: var(--text-secondary); color: var(--text-secondary);
} }
.reminder-days-left {
color: var(--warning);
font-size: 0.8rem;
}
.critical .reminder-days-left {
color: var(--danger);
}
.med-link {
font-weight: 600;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.med-link.clickable {
cursor: pointer;
}
.med-link.clickable:hover {
color: var(--accent);
text-decoration-style: solid;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.reminder-status-bar { .reminder-status-bar {
padding: 0.75rem; padding: 0.75rem;
@@ -300,6 +324,9 @@ body.modal-open {
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
} }
.reminder-low-stock-list {
padding-left: 0;
}
} }
.tabs { .tabs {
@@ -387,6 +414,19 @@ body.modal-open {
color: var(--accent-light); color: var(--accent-light);
} }
.edit-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.edit-header h2 {
margin: 0;
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-full { .schedule-full {
max-width: 100%; max-width: 100%;
} }
@@ -468,6 +508,11 @@ body.modal-open {
background 200ms ease, background 200ms ease,
border-color 200ms ease; border-color 200ms ease;
} }
.med-row.editing {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
box-shadow: 0 0 0 1px var(--accent);
}
.med-header { .med-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -928,6 +973,27 @@ textarea.auto-resize {
gap: 0.75rem; gap: 0.75rem;
} }
/* Package type selector - simple dropdown style */
.package-type-select {
width: 100%;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 0.9rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.package-type-select:focus {
outline: none;
border-color: var(--accent);
}
/* Form field validation */ /* Form field validation */
.form-grid label.has-error input, .form-grid label.has-error input,
.form-grid label.has-error textarea { .form-grid label.has-error textarea {
@@ -949,6 +1015,40 @@ textarea.auto-resize {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Dose input with unit selector */
.dose-input-group {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.dose-input-group input {
flex: 1;
min-width: 0;
}
.dose-unit-select {
width: auto;
min-width: 80px;
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--input-radius);
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
transition: border-color 150ms ease;
}
.dose-unit-select:focus {
outline: none;
border-color: var(--accent);
}
.dose-unit-select:hover {
border-color: var(--accent);
}
/* Tag input for multi-value fields (e.g., Taken By) */ /* Tag input for multi-value fields (e.g., Taken By) */
.tag-input-container { .tag-input-container {
display: flex; display: flex;
@@ -3856,6 +3956,41 @@ h3 .reminder-icon.info-tooltip {
cursor: pointer; cursor: pointer;
} }
/* Password Input with Toggle */
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-input-wrapper input {
width: 100%;
padding-right: 2.5rem;
}
.password-toggle-btn {
position: absolute;
right: 0.5rem;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
transition: color 0.15s ease;
}
.password-toggle-btn:hover {
color: var(--text-secondary);
}
.password-toggle-btn svg {
width: 1.25rem;
height: 1.25rem;
}
.auth-links { .auth-links {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -4164,7 +4299,8 @@ h3 .reminder-icon.info-tooltip {
.profile-modal { .profile-modal {
max-width: 420px; max-width: 420px;
padding: 0; padding: 0;
overflow: hidden; overflow-y: auto;
max-height: 90vh;
} }
.profile-container { .profile-container {
@@ -4269,8 +4405,7 @@ h3 .reminder-icon.info-tooltip {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-bottom: 0.5rem; padding-bottom: 0;
border-bottom: 1px solid var(--border-primary);
} }
.profile-form .form-group { .profile-form .form-group {
@@ -4309,9 +4444,8 @@ h3 .reminder-icon.info-tooltip {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
justify-content: flex-end; justify-content: flex-end;
padding-top: 0.5rem; padding-top: 0;
border-top: 1px solid var(--border-primary); margin-top: 0.5rem;
margin-top: 1rem;
} }
.profile-actions .btn { .profile-actions .btn {
@@ -4348,6 +4482,36 @@ h3 .reminder-icon.info-tooltip {
cursor: not-allowed; cursor: not-allowed;
} }
/* Profile danger zone */
.profile-danger-zone {
margin: 0 1.5rem 1.5rem;
padding-top: 1rem;
}
.profile-danger-zone .profile-section-title {
margin-bottom: 0.75rem;
}
.btn-danger {
background: #dc2626;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================================================= /* =============================================================================
About Modal About Modal
============================================================================= */ ============================================================================= */
@@ -14,6 +14,15 @@ vi.mock("react-router-dom", async () => {
}; };
}); });
// Mock useUnsavedChanges
vi.mock("../../context", () => ({
useUnsavedChanges: () => ({
setHasUnsavedChanges: vi.fn(),
hasUnsavedChanges: false,
confirmNavigation: vi.fn().mockReturnValue(true),
}),
}));
describe("AppHeader", () => { describe("AppHeader", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7, lowStockDays: 7,
normalStockDays: 30, normalStockDays: 30,
highStockDays: 90, highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
}; };
const mockMedication: Medication = { const mockMedication: Medication = {
@@ -7,10 +7,12 @@ const defaultForm: FormState = {
name: "", name: "",
genericName: "", genericName: "",
takenBy: [], takenBy: [],
packageType: "blister",
packCount: "1", packCount: "1",
blistersPerPack: "1", blistersPerPack: "1",
pillsPerBlister: "1", pillsPerBlister: "1",
looseTablets: "0", looseTablets: "0",
totalPills: "",
pillWeightMg: "", pillWeightMg: "",
expiryDate: "", expiryDate: "",
notes: "", notes: "",
@@ -23,6 +25,16 @@ const defaultForm: FormState = {
startTime: "09:00", startTime: "09:00",
}, },
], ],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}; };
const defaultProps = { const defaultProps = {
@@ -44,6 +56,9 @@ const defaultProps = {
onSetBlisterValue: vi.fn(), onSetBlisterValue: vi.fn(),
onAddBlister: vi.fn(), onAddBlister: vi.fn(),
onRemoveBlister: vi.fn(), onRemoveBlister: vi.fn(),
onSetIntakeValue: vi.fn(),
onAddIntake: vi.fn(),
onRemoveIntake: vi.fn(),
onHandleValueChange: vi.fn(), onHandleValueChange: vi.fn(),
refillPacks: 0, refillPacks: 0,
onRefillPacksChange: vi.fn(), onRefillPacksChange: vi.fn(),
@@ -99,8 +114,7 @@ describe("MobileEditModal", () => {
it("calls onClose when close button clicked", () => { it("calls onClose when close button clicked", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const onResetForm = vi.fn(); render(<MobileEditModal {...defaultProps} onClose={onClose} />);
render(<MobileEditModal {...defaultProps} onClose={onClose} onResetForm={onResetForm} />);
const closeBtn = document.querySelector(".modal-close"); const closeBtn = document.querySelector(".modal-close");
if (closeBtn) { if (closeBtn) {
@@ -108,7 +122,6 @@ describe("MobileEditModal", () => {
} }
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
expect(onResetForm).toHaveBeenCalledTimes(1);
}); });
it("renders form element", () => { it("renders form element", () => {
@@ -187,14 +200,14 @@ describe("MobileEditModal", () => {
expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument(); expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument();
}); });
it("calls onAddBlister when add intake clicked", () => { it("calls onAddIntake when add intake clicked", () => {
const onAddBlister = vi.fn(); const onAddIntake = vi.fn();
render(<MobileEditModal {...defaultProps} onAddBlister={onAddBlister} />); render(<MobileEditModal {...defaultProps} onAddIntake={onAddIntake} />);
const addBtn = screen.getByText(/form\.blisters\.addIntake/i); const addBtn = screen.getByText(/form\.blisters\.addIntake/i);
fireEvent.click(addBtn); fireEvent.click(addBtn);
expect(onAddBlister).toHaveBeenCalledTimes(1); expect(onAddIntake).toHaveBeenCalledTimes(1);
}); });
it("renders modal content", () => { it("renders modal content", () => {
@@ -263,6 +276,24 @@ describe("MobileEditModal blister management", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }, { usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" }, { usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
], ],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}; };
render(<MobileEditModal {...defaultProps} form={form} />); render(<MobileEditModal {...defaultProps} form={form} />);
@@ -271,34 +302,52 @@ describe("MobileEditModal blister management", () => {
expect(blisterRows.length).toBe(2); expect(blisterRows.length).toBe(2);
}); });
it("calls onRemoveBlister when remove button clicked", () => { it("calls onRemoveIntake when remove button clicked", () => {
const onRemoveBlister = vi.fn(); const onRemoveIntake = vi.fn();
const form = { const form = {
...defaultForm, ...defaultForm,
blisters: [ blisters: [
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }, { usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" }, { usage: "2", every: "7", startDate: "2024-01-01", startTime: "10:00" },
], ],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "10:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}; };
render(<MobileEditModal {...defaultProps} form={form} onRemoveBlister={onRemoveBlister} />); render(<MobileEditModal {...defaultProps} form={form} onRemoveIntake={onRemoveIntake} />);
const removeButtons = document.querySelectorAll(".blister-row button.danger"); const removeButtons = document.querySelectorAll(".blister-row button.danger");
if (removeButtons.length > 0) { if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]); fireEvent.click(removeButtons[0]);
expect(onRemoveBlister).toHaveBeenCalled(); expect(onRemoveIntake).toHaveBeenCalled();
} }
}); });
it("calls onSetBlisterValue when changing blister field", () => { it("calls onSetIntakeValue when changing blister field", () => {
const onSetBlisterValue = vi.fn(); const onSetIntakeValue = vi.fn();
render(<MobileEditModal {...defaultProps} onSetBlisterValue={onSetBlisterValue} />); render(<MobileEditModal {...defaultProps} onSetIntakeValue={onSetIntakeValue} />);
const usageInputs = document.querySelectorAll('.blister-row input[type="number"]'); const usageInputs = document.querySelectorAll('.blister-row input[type="number"]');
if (usageInputs.length > 0) { if (usageInputs.length > 0) {
fireEvent.change(usageInputs[0], { target: { value: "2" } }); fireEvent.change(usageInputs[0], { target: { value: "2" } });
expect(onSetBlisterValue).toHaveBeenCalled(); expect(onSetIntakeValue).toHaveBeenCalled();
} }
}); });
}); });
@@ -310,8 +359,9 @@ describe("MobileEditModal form submission", () => {
it("calls onSaveMedication when form submitted", () => { it("calls onSaveMedication when form submitted", () => {
const onSaveMedication = vi.fn((e: Event) => e.preventDefault()); const onSaveMedication = vi.fn((e: Event) => e.preventDefault());
const validForm = { ...defaultForm, name: "TestMed" };
render(<MobileEditModal {...defaultProps} onSaveMedication={onSaveMedication} />); render(<MobileEditModal {...defaultProps} form={validForm} onSaveMedication={onSaveMedication} />);
const form = document.querySelector("form"); const form = document.querySelector("form");
if (form) { if (form) {
@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PasswordInput } from "../../components/PasswordInput";
describe("PasswordInput", () => {
it("renders password input with hidden text by default", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input.type).toBe("password");
});
it("toggles password visibility when eye button is clicked", () => {
render(<PasswordInput id="test-password" value="secret123" onChange={() => {}} />);
const input = document.getElementById("test-password") as HTMLInputElement;
const toggleButton = screen.getByRole("button", { name: /show password/i });
// Initially password is hidden
expect(input.type).toBe("password");
// Click to show password
fireEvent.click(toggleButton);
expect(input.type).toBe("text");
// Click again to hide password
fireEvent.click(toggleButton);
expect(input.type).toBe("password");
});
it("calls onChange when input value changes", () => {
const handleChange = vi.fn();
render(<PasswordInput id="test-password" value="" onChange={handleChange} />);
const input = document.getElementById("test-password") as HTMLInputElement;
fireEvent.change(input, { target: { value: "newpassword" } });
expect(handleChange).toHaveBeenCalled();
});
it("passes through required attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} required />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.required).toBe(true);
});
it("passes through minLength and maxLength attributes", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} minLength={8} maxLength={128} />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.minLength).toBe(8);
expect(input.maxLength).toBe(128);
});
it("passes through placeholder attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} placeholder="Enter password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.placeholder).toBe("Enter password");
});
it("passes through autoComplete attribute", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} autoComplete="new-password" />);
const input = document.getElementById("test-password") as HTMLInputElement;
expect(input.autocomplete).toBe("new-password");
});
it("toggle button has correct aria-label", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button", { name: /show password/i });
expect(toggleButton).toBeInTheDocument();
fireEvent.click(toggleButton);
const hideButton = screen.getByRole("button", { name: /hide password/i });
expect(hideButton).toBeInTheDocument();
});
it("toggle button has tabIndex -1 to prevent focus during form navigation", () => {
render(<PasswordInput id="test-password" value="" onChange={() => {}} />);
const toggleButton = screen.getByRole("button");
expect(toggleButton.tabIndex).toBe(-1);
});
});
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,8 @@ const defaultSettings: StockThresholds = {
lowStockDays: 7, lowStockDays: 7,
normalStockDays: 30, normalStockDays: 30,
highStockDays: 90, highStockDays: 90,
criticalStockDays: 3,
expiryWarningDays: 30,
}; };
const mockMedication: Medication = { const mockMedication: Medication = {
@@ -42,7 +42,7 @@ describe("useMedications", () => {
expect(result.current.meds).toEqual(mockMeds); expect(result.current.meds).toEqual(mockMeds);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications"); expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
}); });
it("handles API error gracefully", async () => { it("handles API error gracefully", async () => {
@@ -104,7 +104,7 @@ describe("useMedications", () => {
await result.current.deleteMed(1, 1, mockResetForm); await result.current.deleteMed(1, 1, mockResetForm);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE" }); expect(fetch).toHaveBeenCalledWith("/api/medications/1", { method: "DELETE", credentials: "include" });
expect(mockResetForm).toHaveBeenCalled(); expect(mockResetForm).toHaveBeenCalled();
}); });
@@ -168,7 +168,7 @@ describe("useMedications", () => {
await result.current.deleteMedImage(1); await result.current.deleteMedImage(1);
}); });
expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE" }); expect(fetch).toHaveBeenCalledWith("/api/medications/1/image", { method: "DELETE", credentials: "include" });
}); });
it("allows setting meds directly", () => { it("allows setting meds directly", () => {
+25 -5
View File
@@ -139,6 +139,13 @@ const createMockAppContext = (overrides = {}) => ({
coverage: { all: [], low: [] }, coverage: { all: [], low: [] },
coverageByMed: {}, coverageByMed: {},
depletionByMed: {}, depletionByMed: {},
stockThresholds: {
lowStockDays: 7,
normalStockDays: 30,
highStockDays: 90,
criticalStockDays: 7,
expiryWarningDays: 30,
},
manuallyExpandedDays: new Set(), manuallyExpandedDays: new Set(),
manuallyCollapsedDays: new Set(), manuallyCollapsedDays: new Set(),
toggleDayCollapse: vi.fn(), toggleDayCollapse: vi.fn(),
@@ -400,8 +407,8 @@ describe("DashboardPage structure", () => {
// Should have all expected table columns // Should have all expected table columns
expect(screen.getByText(/table\.name/i)).toBeInTheDocument(); expect(screen.getByText(/table\.name/i)).toBeInTheDocument();
expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument(); expect(screen.getByText(/table\.stock(?!Details)/i)).toBeInTheDocument();
expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument(); expect(screen.getByText(/table\.stockDetails/i)).toBeInTheDocument();
expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument(); expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument();
expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument(); expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument();
expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument(); expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument();
@@ -482,6 +489,17 @@ describe("DashboardPage with medications", () => {
}); });
it("renders schedule timeline with future doses", () => { it("renders schedule timeline with future doses", () => {
// Need showFutureDays: true for day-blocks to render
mockContextValue = createMockAppContext({
meds: mockMeds,
coverage: mockCoverage,
coverageByMed: {
Aspirin: mockCoverage.all[0],
},
futureDays: mockFutureDays,
showFutureDays: true,
});
render( render(
<MemoryRouter> <MemoryRouter>
<DashboardPage /> <DashboardPage />
@@ -548,6 +566,7 @@ describe("DashboardPage with email notifications", () => {
settings: { settings: {
...createMockAppContext().settings, ...createMockAppContext().settings,
emailEnabled: true, emailEnabled: true,
emailStockReminders: true,
notificationEmail: "test@example.com", notificationEmail: "test@example.com",
}, },
}); });
@@ -565,15 +584,15 @@ describe("DashboardPage with email notifications", () => {
expect(statusBar).toBeInTheDocument(); expect(statusBar).toBeInTheDocument();
}); });
it("shows reminder email button when there are low stock meds", () => { it("hides reorder reminder card when reminders are enabled (to avoid redundancy)", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<DashboardPage /> <DashboardPage />
</MemoryRouter> </MemoryRouter>
); );
// Should show send reminder button // Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument(); expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
}); });
}); });
@@ -587,6 +606,7 @@ describe("DashboardPage with shoutrrr notifications", () => {
settings: { settings: {
...createMockAppContext().settings, ...createMockAppContext().settings,
shoutrrrEnabled: true, shoutrrrEnabled: true,
shoutrrrStockReminders: true,
}, },
}); });
}); });
+111 -24
View File
@@ -57,6 +57,7 @@ const createMockContext = (overrides = {}) => ({
setRefillLoose: vi.fn(), setRefillLoose: vi.fn(),
refillSaving: false, refillSaving: false,
submitRefill: vi.fn(), submitRefill: vi.fn(),
coverageByMed: {},
...overrides, ...overrides,
}); });
@@ -65,12 +66,24 @@ const createMockFormHook = (overrides = {}) => ({
form: { form: {
name: "", name: "",
genericName: "", genericName: "",
packageType: "blister" as const,
packCount: "0", packCount: "0",
blistersPerPack: "0", blistersPerPack: "0",
pillsPerBlister: "1", pillsPerBlister: "1",
looseTablets: "0", looseTablets: "0",
totalPills: "",
takenBy: [], takenBy: [],
blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }], blisters: [{ usage: "1", every: "1", startDate: new Date().toISOString().slice(0, 10), startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: new Date().toISOString().slice(0, 10),
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "", expiryDate: "",
notes: "", notes: "",
pillWeightMg: "", pillWeightMg: "",
@@ -93,6 +106,9 @@ const createMockFormHook = (overrides = {}) => ({
addBlister: vi.fn(), addBlister: vi.fn(),
removeBlister: vi.fn(), removeBlister: vi.fn(),
setBlisterValue: vi.fn(), setBlisterValue: vi.fn(),
addIntake: vi.fn(),
removeIntake: vi.fn(),
setIntakeValue: vi.fn(),
resetForm: vi.fn(), resetForm: vi.fn(),
startEdit: vi.fn(), startEdit: vi.fn(),
showEditModal: false, showEditModal: false,
@@ -110,11 +126,17 @@ let mockFormHookValue = createMockFormHook();
// Mock the hooks // Mock the hooks
vi.mock("../../hooks", () => ({ vi.mock("../../hooks", () => ({
useMedicationForm: () => mockFormHookValue, useMedicationForm: () => mockFormHookValue,
useUnsavedChangesWarning: () => ({}),
})); }));
// Mock the context // Mock the context
vi.mock("../../context", () => ({ vi.mock("../../context", () => ({
useAppContext: () => mockContextValue, useAppContext: () => mockContextValue,
useUnsavedChanges: () => ({
setHasUnsavedChanges: vi.fn(),
hasUnsavedChanges: false,
confirmNavigation: vi.fn().mockReturnValue(true),
}),
})); }));
describe("MedicationsPage", () => { describe("MedicationsPage", () => {
@@ -322,9 +344,9 @@ describe("MedicationsPage form interactions", () => {
} }
}); });
it("calls addBlister when clicking add schedule button", () => { it("calls addIntake when clicking add schedule button", () => {
const addBlister = vi.fn(); const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister }); mockFormHookValue = createMockFormHook({ addIntake });
render( render(
<MemoryRouter> <MemoryRouter>
@@ -332,11 +354,11 @@ describe("MedicationsPage form interactions", () => {
</MemoryRouter> </MemoryRouter>
); );
// Find add blister button // Find add intake button
const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/); const addBtn = screen.queryByText(/form\.blisters\.add/i) || screen.queryByText(/\+/);
if (addBtn) { if (addBtn) {
fireEvent.click(addBtn); fireEvent.click(addBtn);
expect(addBlister).toHaveBeenCalled(); expect(addIntake).toHaveBeenCalled();
} }
}); });
}); });
@@ -387,12 +409,24 @@ describe("MedicationsPage editing", () => {
form: { form: {
name: "Aspirin", name: "Aspirin",
genericName: "Acetylsalicylic acid", genericName: "Acetylsalicylic acid",
packageType: "blister" as const,
packCount: "1", packCount: "1",
blistersPerPack: "2", blistersPerPack: "2",
pillsPerBlister: "10", pillsPerBlister: "10",
looseTablets: "5", looseTablets: "5",
totalPills: "",
takenBy: ["John"], takenBy: ["John"],
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }], blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
expiryDate: "2025-12-31", expiryDate: "2025-12-31",
notes: "Take with food", notes: "Take with food",
pillWeightMg: "", pillWeightMg: "",
@@ -552,14 +586,24 @@ describe("MedicationsPage blister management", () => {
expect(blisterSections.length).toBeGreaterThan(0); expect(blisterSections.length).toBeGreaterThan(0);
}); });
it("calls setBlisterValue when changing blister field", () => { it("calls setIntakeValue when changing blister field", () => {
const setBlisterValue = vi.fn(); const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({ mockFormHookValue = createMockFormHook({
form: { form: {
...createMockFormHook().form, ...createMockFormHook().form,
blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }], blisters: [{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}, },
setBlisterValue, setIntakeValue,
}); });
render( render(
@@ -572,7 +616,7 @@ describe("MedicationsPage blister management", () => {
const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]'); const blisterInputs = document.querySelectorAll('.blister-inputs input[type="number"]');
if (blisterInputs.length > 0) { if (blisterInputs.length > 0) {
fireEvent.change(blisterInputs[0], { target: { value: "2" } }); fireEvent.change(blisterInputs[0], { target: { value: "2" } });
expect(setBlisterValue).toHaveBeenCalled(); expect(setIntakeValue).toHaveBeenCalled();
} }
}); });
}); });
@@ -585,9 +629,9 @@ describe("MedicationsPage add blister", () => {
mockFormHookValue = createMockFormHook(); mockFormHookValue = createMockFormHook();
}); });
it("calls addBlister when clicking add intake button", () => { it("calls addIntake when clicking add intake button", () => {
const addBlister = vi.fn(); const addIntake = vi.fn();
mockFormHookValue = createMockFormHook({ addBlister }); mockFormHookValue = createMockFormHook({ addIntake });
render( render(
<MemoryRouter> <MemoryRouter>
@@ -597,7 +641,7 @@ describe("MedicationsPage add blister", () => {
const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i }); const addIntakeBtn = screen.getByRole("button", { name: /form\.blisters\.addIntake/i });
fireEvent.click(addIntakeBtn); fireEvent.click(addIntakeBtn);
expect(addBlister).toHaveBeenCalled(); expect(addIntake).toHaveBeenCalled();
}); });
}); });
@@ -613,6 +657,24 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }, { usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" }, { usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
], ],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}, },
}); });
}); });
@@ -629,8 +691,8 @@ describe("MedicationsPage remove blister", () => {
expect(removeButtons.length).toBeGreaterThan(0); expect(removeButtons.length).toBeGreaterThan(0);
}); });
it("calls removeBlister when clicking remove button", () => { it("calls removeIntake when clicking remove button", () => {
const removeBlister = vi.fn(); const removeIntake = vi.fn();
mockFormHookValue = createMockFormHook({ mockFormHookValue = createMockFormHook({
form: { form: {
...createMockFormHook().form, ...createMockFormHook().form,
@@ -638,8 +700,26 @@ describe("MedicationsPage remove blister", () => {
{ usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" }, { usage: "1", every: "1", startDate: "2024-01-01", startTime: "09:00" },
{ usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" }, { usage: "2", every: "7", startDate: "2024-01-01", startTime: "20:00" },
], ],
intakes: [
{
usage: "1",
every: "1",
startDate: "2024-01-01",
startTime: "09:00",
takenBy: "",
intakeRemindersEnabled: false,
},
{
usage: "2",
every: "7",
startDate: "2024-01-01",
startTime: "20:00",
takenBy: "",
intakeRemindersEnabled: false,
},
],
}, },
removeBlister, removeIntake,
}); });
render( render(
@@ -651,7 +731,7 @@ describe("MedicationsPage remove blister", () => {
const removeButtons = document.querySelectorAll(".blister-row .danger"); const removeButtons = document.querySelectorAll(".blister-row .danger");
if (removeButtons.length > 0) { if (removeButtons.length > 0) {
fireEvent.click(removeButtons[0]); fireEvent.click(removeButtons[0]);
expect(removeBlister).toHaveBeenCalled(); expect(removeIntake).toHaveBeenCalled();
} }
}); });
}); });
@@ -664,19 +744,25 @@ describe("MedicationsPage intake reminders toggle", () => {
mockFormHookValue = createMockFormHook(); mockFormHookValue = createMockFormHook();
}); });
it("renders intake reminders checkbox", () => { it("renders intake reminders checkbox per intake", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MedicationsPage /> <MedicationsPage />
</MemoryRouter> </MemoryRouter>
); );
expect(screen.getByText(/form\.blisters\.remind/i)).toBeInTheDocument(); // Now each intake row has its own reminder checkbox with the bell icon
// 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();
}); });
it("can toggle intake reminders", () => { it("can toggle intake reminders per intake", () => {
const setForm = vi.fn(); const setIntakeValue = vi.fn();
mockFormHookValue = createMockFormHook({ setForm }); mockFormHookValue = createMockFormHook({ setIntakeValue });
render( render(
<MemoryRouter> <MemoryRouter>
@@ -684,10 +770,11 @@ describe("MedicationsPage intake reminders toggle", () => {
</MemoryRouter> </MemoryRouter>
); );
const checkbox = document.querySelector('.inline-checkbox input[type="checkbox"]'); // Each blister row has inline-checkbox for intake reminders
const checkbox = document.querySelector('.blister-row .inline-checkbox input[type="checkbox"]');
if (checkbox) { if (checkbox) {
fireEvent.click(checkbox); fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalled(); expect(setIntakeValue).toHaveBeenCalled();
} }
}); });
}); });
@@ -101,6 +101,7 @@ const createMockContext = (overrides = {}) => ({
manuallyExpandedDays: new Set(), manuallyExpandedDays: new Set(),
toggleDayCollapse: vi.fn(), toggleDayCollapse: vi.fn(),
openUserFilter: vi.fn(), openUserFilter: vi.fn(),
missedPastDoseIds: [],
...overrides, ...overrides,
}); });
@@ -436,6 +437,7 @@ describe("SchedulePage with past days", () => {
futureDays: mockFutureDays, futureDays: mockFutureDays,
coverageByMed: mockCoverageByMed, coverageByMed: mockCoverageByMed,
showPastDays: false, showPastDays: false,
missedPastDoseIds: [`1-0-${Date.now() - 86400000}-John`], // One missed dose
}); });
}); });
@@ -467,6 +469,7 @@ describe("SchedulePage with past days", () => {
pastDays: mockPastDays, pastDays: mockPastDays,
showPastDays: false, showPastDays: false,
setShowPastDays, setShowPastDays,
missedPastDoseIds: [],
}); });
render( render(
@@ -5,9 +5,7 @@ import {
compareSemver, compareSemver,
deriveTotal, deriveTotal,
formatDateTime, formatDateTime,
formatFullBlisters,
formatNumber, formatNumber,
formatOpenBlisterAndLoose,
getBlisterStock, getBlisterStock,
getExpiryClass, getExpiryClass,
pad2, pad2,
@@ -227,22 +225,6 @@ describe("getBlisterStock", () => {
}); });
}); });
describe("formatFullBlisters", () => {
it("formats count without pill info", () => {
expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 })).toBe("5");
});
it("formats count with pill info", () => {
expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 }, 10)).toBe("5 (50)");
});
});
describe("formatOpenBlisterAndLoose", () => {
it("formats open blister pills count", () => {
expect(formatOpenBlisterAndLoose({ fullBlisters: 5, openBlisterPills: 7, loosePills: 7 })).toBe("7");
});
});
describe("compareSemver", () => { describe("compareSemver", () => {
it("returns 0 for equal versions", () => { it("returns 0 for equal versions", () => {
expect(compareSemver("1.2.3", "1.2.3")).toBe(0); expect(compareSemver("1.2.3", "1.2.3")).toBe(0);
+3 -3
View File
@@ -466,7 +466,7 @@ describe("getReminderStatusText", () => {
}; };
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en"); const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needReorder"))).toBe(true); expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true);
}); });
it("handles intake reminder type with push channel", () => { it("handles intake reminder type with push channel", () => {
@@ -497,7 +497,7 @@ describe("getReminderStatusText", () => {
expect(result.lines[0].className).toBe("danger-text"); expect(result.lines[0].className).toBe("danger-text");
}); });
it("shows needReorder when below critical threshold", () => { it("shows needRefill when below critical threshold", () => {
const criticalMed: Coverage = { const criticalMed: Coverage = {
name: "Critical", name: "Critical",
medsLeft: 5, medsLeft: 5,
@@ -508,7 +508,7 @@ describe("getReminderStatusText", () => {
}; };
const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en"); const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en");
expect(result.lines.some((l) => l.text.includes("needReorder"))).toBe(true); expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true);
}); });
it("shows low warning when below low threshold but above critical", () => { it("shows low warning when below low threshold but above critical", () => {
+72 -12
View File
@@ -2,29 +2,57 @@
// Core Types for MedAssist // Core Types for MedAssist
// ============================================================================= // =============================================================================
export type PackageType = "blister" | "bottle";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml";
export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
{ value: "mg", label: "mg" },
{ value: "g", label: "g" },
{ value: "mcg", label: "mcg (µg)" },
{ value: "ml", label: "ml" },
];
export type Blister = { export type Blister = {
usage: number; usage: number;
every: number; every: number;
start: string; start: string;
}; };
/**
* Intake with per-intake takenBy support.
* Extends Blister with per-intake user assignment.
*/
export type Intake = {
usage: number;
every: number;
start: string;
takenBy: string | null; // Per-intake user assignment (single person or null)
intakeRemindersEnabled: boolean;
};
export type Medication = { export type Medication = {
id: number; id: number;
name: string; name: string;
genericName?: string | null; genericName?: string | null;
takenBy: string[]; takenBy: string[]; // Medication-level takenBy (legacy, still used for filtering)
packageType: PackageType;
packCount: number; packCount: number;
blistersPerPack: number; blistersPerPack: number;
pillsPerBlister: number; pillsPerBlister: number;
looseTablets: number; totalPills?: number | null; // For bottle type: total capacity of the container
looseTablets: number; // For blister: extra loose pills; for bottle: current stock
stockAdjustment?: number; stockAdjustment?: number;
lastStockCorrectionAt?: string | null; lastStockCorrectionAt?: string | null;
pillWeightMg?: number | null; pillWeightMg?: number | null;
blisters: Blister[]; doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
imageUrl?: string | null; imageUrl?: string | null;
expiryDate?: string | null; expiryDate?: string | null;
notes?: string | null; notes?: string | null;
intakeRemindersEnabled?: boolean; intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
updatedAt: string | number | null; updatedAt: string | number | null;
}; };
@@ -55,19 +83,35 @@ export type FormBlister = {
startTime: string; startTime: string;
}; };
/**
* Form state for intake entry with per-intake takenBy support.
*/
export type FormIntake = {
usage: string;
every: string;
startDate: string;
startTime: string;
takenBy: string; // Single person or empty string (empty = null for everyone)
intakeRemindersEnabled: boolean;
};
export type FormState = { export type FormState = {
name: string; name: string;
genericName: string; genericName: string;
takenBy: string[]; takenBy: string[]; // Medication-level takenBy (legacy/compatibility)
packageType: PackageType;
packCount: string; packCount: string;
blistersPerPack: string; blistersPerPack: string;
pillsPerBlister: string; pillsPerBlister: string;
looseTablets: string; totalPills: string; // For bottle type: total capacity
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
pillWeightMg: string; pillWeightMg: string;
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
expiryDate: string; expiryDate: string;
notes: string; notes: string;
intakeRemindersEnabled: boolean; intakeRemindersEnabled: boolean; // Deprecated, kept for backward compat
blisters: FormBlister[]; blisters: FormBlister[]; // Legacy form format
intakes: FormIntake[]; // New form format with per-intake takenBy
}; };
export type FieldErrors = { export type FieldErrors = {
@@ -87,7 +131,7 @@ export type Coverage = {
}; };
export type StockStatus = { export type StockStatus = {
level: "out-of-stock" | "low" | "normal" | "high"; level: "out-of-stock" | "critical" | "low" | "normal" | "high";
className: string; className: string;
label: string; label: string;
}; };
@@ -96,6 +140,8 @@ export type StockThresholds = {
lowStockDays: number; lowStockDays: number;
normalStockDays: number; normalStockDays: number;
highStockDays: number; highStockDays: number;
criticalStockDays: number; // Threshold for critical/danger status (typically reminderDaysBefore)
expiryWarningDays: number; // Days before expiry to show warning
}; };
export type ScheduleEvent = { export type ScheduleEvent = {
@@ -106,7 +152,7 @@ export type ScheduleEvent = {
usage: number; usage: number;
when: number; when: number;
isPast: boolean; isPast: boolean;
takenBy: string[]; takenBy: string | null; // Per-intake takenBy (single person or null)
}; };
export type BlisterStock = { export type BlisterStock = {
@@ -121,15 +167,18 @@ export type SharedMedication = {
name: string; name: string;
genericName?: string | null; genericName?: string | null;
pillWeightMg?: number | null; pillWeightMg?: number | null;
doseUnit?: DoseUnit | null;
imageUrl?: string | null; imageUrl?: string | null;
totalPills: number; totalPills: number;
packCount: number; packCount: number;
blistersPerPack: number; blistersPerPack: number;
looseTablets: number; looseTablets: number;
pillsPerBlister: number; pillsPerBlister: number;
takenBy: string[]; takenBy: string[]; // Medication-level takenBy (legacy)
blisters: Blister[]; blisters: Blister[]; // Legacy array format
intakes?: Intake[]; // New intake format with per-intake takenBy
dismissedUntil?: string | null; dismissedUntil?: string | null;
updatedAt?: string | number | null; // For filtering out doses from previous schedule configurations
}; };
export type SharedScheduleData = { export type SharedScheduleData = {
@@ -164,14 +213,25 @@ export const FIELD_LIMITS = {
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & { type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
stockAdjustment?: number; stockAdjustment?: number;
packageType?: PackageType;
}; };
/** Calculate total pills including stockAdjustment */ /** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number { export function getMedTotal(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0); return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
} }
/** Get the base package size (without stockAdjustment) */ /** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number { export function getPackageSize(med: MedLike): number {
// For bottle type, looseTablets IS the current stock
if (med.packageType === "bottle") {
return med.looseTablets;
}
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets; return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
} }
-18
View File
@@ -232,24 +232,6 @@ export function getBlisterStock(med: Medication): BlisterStock {
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills }; return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
} }
/**
* Format full blisters count with optional pills per blister
*/
export function formatFullBlisters(stock: BlisterStock, pillsPerBlister?: number): string {
const count = stock.fullBlisters;
if (pillsPerBlister !== undefined) {
return `${count} (${count * pillsPerBlister})`;
}
return String(count);
}
/**
* Format open blister and loose pills
*/
export function formatOpenBlisterAndLoose(stock: BlisterStock): string {
return String(stock.openBlisterPills);
}
/** /**
* Compare semantic version strings * Compare semantic version strings
* Returns: negative if a < b, positive if a > b, 0 if equal * Returns: negative if a < b, positive if a > b, 0 if equal
+125 -19
View File
@@ -2,9 +2,37 @@
// Schedule Building and Coverage Calculations // Schedule Building and Coverage Calculations
// ============================================================================= // =============================================================================
import type { Coverage, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types"; import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedTotal } from "../types"; import { getMedTotal } from "../types";
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
}
/** /**
* Build schedule preview events for medications * Build schedule preview events for medications
*/ */
@@ -22,18 +50,22 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => { meds.forEach((med) => {
med.blisters.forEach((blister, idx) => { const intakes = getIntakesForMed(med);
const start = new Date(blister.start); intakes.forEach((intake, idx) => {
const start = new Date(intake.start);
if (Number.isNaN(start.getTime())) return; if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) { for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
const isPast = d < todayStart; const isPast = d < todayStart;
if (isPast && !includePast) continue; if (isPast && !includePast) continue;
const whenMs = d.getTime(); const whenMs = d.getTime();
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
events.push({ events.push({
id: `${med.id}-${idx}-${whenMs}`, id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name, medName: med.name,
takenBy: med.takenBy || [], takenBy: intake.takenBy, // Per-intake takenBy (string | null)
usage: blister.usage, usage: intake.usage,
when: whenMs, when: whenMs,
isPast, isPast,
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }), timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
@@ -55,7 +87,7 @@ export function buildSchedulePreview(
events, events,
today: todayCount, today: todayCount,
nextThree: events.length, nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0), totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
}; };
} }
@@ -64,7 +96,7 @@ export function buildSchedulePreview(
*/ */
export function calculateCoverage( export function calculateCoverage(
meds: Medication[], meds: Medication[],
events: Array<{ medName: string; when: number }>, events: Array<{ medName: string; when: number; id: string }>,
locale: string, locale: string,
reminderDaysBefore: number, reminderDaysBefore: number,
stockCalculationMode: "automatic" | "manual", stockCalculationMode: "automatic" | "manual",
@@ -74,32 +106,96 @@ export function calculateCoverage(
const now = Date.now(); const now = Date.now();
const coverage: Coverage[] = meds.map((m) => { const coverage: Coverage[] = meds.map((m) => {
const personCount = Math.max(1, m.takenBy?.length || 1); const intakes = getIntakesForMed(m);
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount; const blisters = getBlistersForMed(m);
// 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);
});
// Also add medication-level takenBy for backward compatibility
m.takenBy?.forEach((person) => uniquePeople.add(person));
const personCount = Math.max(1, uniquePeople.size || m.takenBy?.length || 1);
const dailyRate = blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
let consumed = 0; let consumed = 0;
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0; const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
if (stockCalculationMode === "automatic") { if (stockCalculationMode === "automatic") {
m.blisters.forEach((s) => { // In automatic mode, calculate expected consumption based on time
// but also account for manual corrections (doses marked as not taken)
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime(); const blisterStart = new Date(s.start).getTime();
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff); const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
if (Number.isNaN(effectiveStart) || effectiveStart > now) return; if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY; const period = Math.max(1, s.every) * MS_PER_DAY;
const occurrences = Math.floor((now - effectiveStart) / period) + 1; const occurrences = Math.floor((now - effectiveStart) / period) + 1;
consumed += occurrences * s.usage * personCount; const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
// For legacy (no takenBy), count for all people in medication takenBy
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
// Count how many doses were actually marked as taken for this blister
let actualConsumed = 0;
// Generate all expected dose IDs for this blister up to now
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoses.has(doseId)) {
actualConsumed += s.usage;
}
}
}
// If we have tracking data (any doses marked), use actual consumed
// Otherwise fall back to expected (for backwards compatibility)
const hasTrackingData = Array.from(takenDoses).some((id) => {
const parts = id.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
});
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
}); });
} else { } else {
// In manual mode, only count doses that are explicitly marked as taken
takenDoses.forEach((doseId) => { takenDoses.forEach((doseId) => {
const parts = doseId.split("-"); const parts = doseId.split("-");
if (parts.length >= 3) { if (parts.length >= 3) {
const medId = parseInt(parts[0], 10); const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10); const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10); const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && m.blisters[blisterIdx]) { if (medId === m.id && blisters[blisterIdx]) {
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime(); // Convert blister start to date-only for comparison (dose timestamps are date-only)
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) { const blisterStartDate = new Date(blisters[blisterIdx].start);
consumed += m.blisters[blisterIdx].usage; const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
// Convert stock correction cutoff to date-only as well
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
doseTimestamp >= stockCorrectionDateOnly
) {
consumed += blisters[blisterIdx].usage;
} }
} }
} }
@@ -143,22 +239,32 @@ export function calculateCoverage(
* Get stock status based on days left and thresholds * Get stock status based on days left and thresholds
*/ */
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus { export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) { if (medsLeft <= 0 || daysLeft === 0) {
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" }; return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
} }
// No schedule, but has stock = normal
if (daysLeft === null) { if (daysLeft === null) {
return { level: "normal", className: "success", label: "status.noSchedule" }; return { level: "normal", className: "success", label: "status.noSchedule" };
} }
// High stock
if (daysLeft > thresholds.highStockDays) { if (daysLeft > thresholds.highStockDays) {
return { level: "high", className: "high", label: "status.highStock" }; return { level: "high", className: "high", label: "status.highStock" };
} }
// Normal stock
if (daysLeft >= thresholds.lowStockDays) { if (daysLeft >= thresholds.lowStockDays) {
return { level: "normal", className: "success", label: "status.normal" }; return { level: "normal", className: "success", label: "status.normal" };
} }
// Critical: at or below critical threshold = danger (red)
if (daysLeft <= thresholds.criticalStockDays) {
return { level: "critical", className: "danger", label: "status.criticalStock" };
}
// Low stock: below lowStockDays but above critical = warning (yellow)
return { level: "low", className: "warning", label: "status.lowStock" }; return { level: "low", className: "warning", label: "status.lowStock" };
} }
@@ -234,7 +340,7 @@ export function getReminderStatusText(
}); });
if (medsNeedingReminder.length > 0) { if (medsNeedingReminder.length > 0) {
lines.push({ lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text", className: "danger-text",
}); });
} }
@@ -252,7 +358,7 @@ export function getReminderStatusText(
if (medsNeedingReminder.length > 0) { if (medsNeedingReminder.length > 0) {
lines.push({ lines.push({
text: `${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`, text: `${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
className: "danger-text", className: "danger-text",
strong: true, strong: true,
}); });
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// Timeout settings to prevent CI hanging
testTimeout: 10000,
hookTimeout: 10000,
teardownTimeout: 5000,
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],