Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 571ab00918 | |||
| 27f5478dad | |||
| 5cd519be50 | |||
| e0c5eb4bf3 | |||
| aa92bcd96d | |||
| 1798a608bc | |||
| 2ec9db1c13 | |||
| 042f0cfb29 | |||
| 78a0d3ac8e | |||
| 7d6664e684 | |||
| 2a84a43654 | |||
| 99bb9c3931 | |||
| 6b3a7b4104 | |||
| 2d9cd0ad1a | |||
| 098a7655a5 | |||
| f73c79c6cf | |||
| 06943f5831 | |||
| 73b3eb6686 | |||
| a4313afc34 |
@@ -0,0 +1,305 @@
|
||||
---
|
||||
name: release-manager
|
||||
description: Manages the full release lifecycle - from branching and PRs through versioning and GitHub release notes. Use when code changes are complete and ready to ship.
|
||||
argument-hint: Describe what was changed, e.g., "fix stock correction bug" or "new refill tracking feature"
|
||||
---
|
||||
|
||||
# Release Manager Agent
|
||||
|
||||
You are the release manager for **MedAssist-ng**. Your job is to guide code from "done" to "released" following the project's strict branch protection, CI pipeline, and semantic versioning rules.
|
||||
|
||||
**All output (commits, PR titles, release notes) MUST be in English**, even if the user communicates in German.
|
||||
|
||||
## Critical Safety Rules
|
||||
|
||||
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
|
||||
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
|
||||
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Branch, PR, and Merge Workflow
|
||||
|
||||
When code changes (features or bug fixes) are complete and tested locally:
|
||||
|
||||
### Step 1: Verify Readiness
|
||||
|
||||
1. Check for uncommitted changes: `git status`
|
||||
2. Ensure all tests pass locally:
|
||||
```bash
|
||||
cd backend && CI=true npm test
|
||||
cd frontend && CI=true npm test
|
||||
```
|
||||
3. If tests fail, stop and fix them first.
|
||||
|
||||
### Step 2: Create Feature Branch
|
||||
|
||||
1. Determine branch name from the change type:
|
||||
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
|
||||
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
|
||||
- Chore: `chore/short-description`
|
||||
2. Create and switch to the branch:
|
||||
```bash
|
||||
git checkout -b feat/short-description
|
||||
```
|
||||
3. Stage and commit changes with a conventional commit message:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: short description of what was fixed"
|
||||
```
|
||||
Commit message prefixes: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`
|
||||
|
||||
### Step 3: Push and Create PR
|
||||
|
||||
1. Push the branch:
|
||||
```bash
|
||||
git push -u origin feat/short-description
|
||||
```
|
||||
2. Create a Pull Request via GitHub CLI:
|
||||
```bash
|
||||
gh pr create --title "fix: short description" --body "Description of charges"
|
||||
```
|
||||
3. **Present the PR URL to the user and wait for confirmation.**
|
||||
|
||||
### Step 4: Wait for CI and Merge
|
||||
|
||||
1. Monitor CI status:
|
||||
```bash
|
||||
gh pr checks <PR_NUMBER> --watch
|
||||
```
|
||||
Required checks:
|
||||
- ✅ `backend-test` (TypeScript type-check + vitest coverage)
|
||||
- ✅ `frontend-build` (npm build)
|
||||
2. If CI fails: analyze the failure, fix it, push again, and re-check.
|
||||
3. Once CI is green, **ask the user for merge confirmation**, then:
|
||||
```bash
|
||||
gh pr merge <PR_NUMBER> --squash --delete-branch
|
||||
```
|
||||
4. Switch back to main and pull:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Determine Version Number
|
||||
|
||||
When the user wants to create a release:
|
||||
|
||||
### Step 1: Check Current Version
|
||||
|
||||
```bash
|
||||
grep '"version"' backend/package.json
|
||||
```
|
||||
|
||||
Also check the latest git tag:
|
||||
```bash
|
||||
git tag --sort=-v:refname | head -5
|
||||
```
|
||||
|
||||
### Step 2: Analyze Changes Since Last Release
|
||||
|
||||
```bash
|
||||
git log $(git describe --tags --abbrev=0)..HEAD --oneline
|
||||
```
|
||||
|
||||
Read through the commits to understand what changed.
|
||||
|
||||
### Step 3: Select SemVer Level
|
||||
|
||||
Apply these rules strictly:
|
||||
|
||||
| Change Type | Version Bump | Example |
|
||||
|------------|-------------|---------|
|
||||
| Bug fixes only, no new features | **patch** | `1.4.2` → `1.4.3` |
|
||||
| New features (backward compatible) | **minor** | `1.4.2` → `1.5.0` |
|
||||
| Breaking changes (DB schema without migration, removed ENV vars, changed API) | **major** | `1.4.2` → `2.0.0` |
|
||||
|
||||
**Guidelines:**
|
||||
- When in doubt between patch and minor, prefer **minor** if any user-visible behavior is new.
|
||||
- Bug fixes that also introduce small UX improvements = **patch**.
|
||||
- Multiple bug fixes in one release = still **patch**.
|
||||
- New UI sections, new API endpoints, new settings = **minor**.
|
||||
- If a user can run `docker compose pull && docker compose up -d` without changing anything → NOT a breaking change.
|
||||
|
||||
**Present your version recommendation to the user with reasoning and wait for confirmation.**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Execute Release
|
||||
|
||||
Use the release script whenever possible:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
This script handles: branch creation → version bump → PR → CI wait → merge → signed tag → push.
|
||||
|
||||
### Version Files (MANDATORY)
|
||||
|
||||
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
|
||||
|
||||
- **`backend/package.json`** → Backend version, returned by `/health` endpoint
|
||||
- **`frontend/package.json`** → Frontend version, injected at build time via Vite's `__APP_VERSION__` define and used to construct the release link
|
||||
|
||||
**Both files MUST be updated to the new version before tagging a release.** If forgotten:
|
||||
- The About modal will show the old version
|
||||
- The version link will point to a non-existent GitHub release page
|
||||
|
||||
### Manual Release (if script is not available)
|
||||
|
||||
1. Create release branch:
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
git checkout -b chore/release-X.Y.Z
|
||||
```
|
||||
2. Update versions in **both** `backend/package.json` and `frontend/package.json` to `X.Y.Z`
|
||||
3. Commit, push, create PR, wait for CI, merge (same as Task 1)
|
||||
4. Create signed tag:
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
git tag -s "vX.Y.Z" -m "Release vX.Y.Z"
|
||||
git push origin "vX.Y.Z"
|
||||
```
|
||||
|
||||
### After Tagging
|
||||
|
||||
- The `docker-build.yml` workflow automatically builds and pushes Docker images to GHCR.
|
||||
- The `version-bump.yml` workflow automatically updates `package.json` versions if needed.
|
||||
- Track progress: `https://github.com/DanielVolz/medassist-ng/actions`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Write Release Notes
|
||||
|
||||
When the user asks to write release notes (MANDATORY for minor/major releases):
|
||||
|
||||
### Step 1: Gather Changes
|
||||
|
||||
```bash
|
||||
git log vPREVIOUS..vNEW --oneline
|
||||
```
|
||||
|
||||
Read the actual code changes (not just commit messages) to understand what was added or fixed.
|
||||
|
||||
### Step 2: Write Release Notes
|
||||
|
||||
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
|
||||
|
||||
**Required structure:**
|
||||
|
||||
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
|
||||
2. **"New Features" / "Bug Fixes" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
|
||||
3. **"Where to Find It"**: Tell users where they can access the new feature or see the fix
|
||||
4. **Breaking Changes Warning** (if applicable): See below
|
||||
|
||||
**Style guidelines:**
|
||||
- Use `### Heading` for sections
|
||||
- Use **bold** for feature names in bullet points
|
||||
- Keep descriptions on the same line as the feature name
|
||||
- Minimal emoji usage (sparingly, not on every line)
|
||||
- Always end with "Where to Find It" section
|
||||
- End with: `**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/vPREV...vNEW`
|
||||
|
||||
**ONLY include user-relevant changes.** DO NOT include:
|
||||
- ❌ Technical implementation details (new columns, endpoints, database changes)
|
||||
- ❌ Number of tests added
|
||||
- ❌ Internal API changes (unless breaking)
|
||||
- ❌ Excessive emoji on every bullet point
|
||||
- ❌ .gitignore changes or other developer-only file changes
|
||||
- ❌ AI/Copilot instruction updates
|
||||
- ❌ CI/CD workflow changes (unless affecting users)
|
||||
- ❌ Code refactoring without user-visible changes
|
||||
|
||||
### Example: Good Release Notes
|
||||
|
||||
```markdown
|
||||
## What's New
|
||||
|
||||
This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
|
||||
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
|
||||
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
|
||||
|
||||
### Mobile Improvements
|
||||
|
||||
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
|
||||
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
|
||||
|
||||
### Where to Find It
|
||||
|
||||
The refill button appears in the medication detail modal and in the edit form for each medication.
|
||||
|
||||
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
|
||||
```
|
||||
|
||||
### Breaking Changes Warning
|
||||
|
||||
If the update breaks existing configurations or stored data, it MUST be prominently warned:
|
||||
|
||||
**Breaking Changes include:**
|
||||
- Database schema changes without automatic migration
|
||||
- Removed or renamed ENV variables
|
||||
- Changed API endpoints
|
||||
- Incompatible `.env` format changes
|
||||
- Loss of stored data after update
|
||||
|
||||
**Format:**
|
||||
|
||||
```markdown
|
||||
## ⚠️ BREAKING CHANGES - Please read before updating!
|
||||
|
||||
**Database migration required**: This update changes the database schema.
|
||||
Existing installations need to:
|
||||
1. Create backup of `data/` folder
|
||||
2. Stop containers
|
||||
3. Perform update
|
||||
4. If issues occur: Rollback using backup
|
||||
|
||||
**ENV variables changed**:
|
||||
- `OLD_VAR` was renamed to `NEW_VAR`
|
||||
- `REMOVED_VAR` is no longer supported
|
||||
```
|
||||
|
||||
**What is NOT a Breaking Change:**
|
||||
- ✅ New optional columns with DEFAULT values
|
||||
- ✅ New ENV variables (with sensible defaults)
|
||||
- ✅ New features that don't affect existing data
|
||||
- ✅ Bug fixes that correct behavior
|
||||
|
||||
### Step 3: Publish
|
||||
|
||||
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish via:
|
||||
```bash
|
||||
gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Workflow Summary
|
||||
|
||||
```
|
||||
Code complete & tests pass locally
|
||||
↓
|
||||
1. Create feature branch (fix/... or feat/...)
|
||||
2. Commit, push, create PR
|
||||
3. Wait for CI (backend-test + frontend-build)
|
||||
4. Merge PR to main (squash + delete branch)
|
||||
↓
|
||||
Ready for release?
|
||||
↓
|
||||
5. Check current version (git tag + package.json)
|
||||
6. Analyze changes → determine SemVer level
|
||||
7. Run ./scripts/release.sh <patch|minor|major>
|
||||
(or manually: branch → version bump → PR → CI → merge → tag)
|
||||
↓
|
||||
8. Write release notes (mandatory for minor/major)
|
||||
9. Publish GitHub release
|
||||
↓
|
||||
Docker images built automatically via CI
|
||||
```
|
||||
@@ -180,141 +180,6 @@ gh pr merge --squash --delete-branch
|
||||
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
||||
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Build and push Docker images |
|
||||
|
||||
### Adding New Code - Checklist
|
||||
1. ✅ Implement feature
|
||||
2. ✅ Write tests for the feature
|
||||
3. ✅ Run `npm run test:coverage` locally
|
||||
4. ✅ Coverage must not decrease
|
||||
5. ✅ Create and push feature branch
|
||||
6. ✅ Create Pull Request
|
||||
7. ✅ Wait until CI is green
|
||||
8. ✅ Merge PR (branch is automatically deleted)
|
||||
|
||||
## GitHub Releases
|
||||
|
||||
> ⚠️ **IMPORTANT**: All GitHub Releases must be written in **English**!
|
||||
|
||||
### Release Workflow (MANDATORY for minor/major releases)
|
||||
|
||||
The `main` branch is protected - releases are created via GitHub's release UI or API.
|
||||
|
||||
**Release Process:**
|
||||
1. Create a new release on GitHub with tag `vX.Y.Z`
|
||||
2. **Automatic Version Bump**: A GitHub Action (`version-bump.yml`) automatically updates `package.json` versions to match the release tag
|
||||
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 release with the written 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.
|
||||
|
||||
**AI Assistant Release Notes Workflow:**
|
||||
1. When user asks to write release notes for a version:
|
||||
- Check commits since previous tag: `git log vPREV..vNEW --oneline`
|
||||
- Read through the changes to understand what was added/fixed
|
||||
- Write release notes following the style guide below
|
||||
- Present the notes to the user for copying to GitHub
|
||||
|
||||
### Creating Release Notes
|
||||
|
||||
> ⚠️ **MANDATORY**: GitHub Releases MUST contain a written message!
|
||||
> Not just auto-generated commit lists, but a brief descriptive text.
|
||||
|
||||
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
|
||||
|
||||
**Keep it informative but concise.** Users want to know what changed and where to find it.
|
||||
|
||||
**Required structure of release notes:**
|
||||
|
||||
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
|
||||
2. **"New Features" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
|
||||
3. **"Where to Find It"**: Tell users where they can access the new feature
|
||||
4. **Breaking Changes Warning** (if applicable): See below
|
||||
|
||||
**Style guidelines:**
|
||||
- Use `### Heading` for sections (New Features, Improvements, Security, etc.)
|
||||
- Use **bold** for feature names in bullet points
|
||||
- Keep descriptions on the same line as the feature name
|
||||
- Minimal emoji usage (sparingly, not on every line)
|
||||
- Always end with "Where to Find It" section
|
||||
|
||||
**DO NOT include:**
|
||||
- ❌ Technical implementation details (new columns, endpoints, database changes)
|
||||
- ❌ Number of tests added
|
||||
- ❌ Internal API changes (unless breaking)
|
||||
- ❌ Excessive emoji on every bullet point
|
||||
- ❌ .gitignore changes or other developer-only file changes
|
||||
- ❌ AI/Copilot instruction updates
|
||||
- ❌ CI/CD workflow changes (unless affecting users)
|
||||
- ❌ Code refactoring without user-visible changes
|
||||
|
||||
**Only include user-relevant changes** - things that affect what users see or experience in the app.
|
||||
|
||||
**Example of good release notes:**
|
||||
|
||||
```markdown
|
||||
## What's New
|
||||
|
||||
This release introduces a medication refill tracking feature and improves the mobile user experience.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history.
|
||||
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill.
|
||||
- **Refill History**: Each medication shows a complete history of all refills with timestamps.
|
||||
|
||||
### Mobile Improvements
|
||||
|
||||
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability.
|
||||
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices.
|
||||
|
||||
### Where to Find It
|
||||
|
||||
The refill button appears in the medication detail modal and in the edit form for each medication.
|
||||
|
||||
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
|
||||
```
|
||||
|
||||
### Breaking Changes Warning (CRITICAL!)
|
||||
|
||||
> ⚠️ **MANDATORY**: If an update breaks existing configurations or stored data, it MUST be prominently warned about in the release notes!
|
||||
|
||||
**Breaking Changes include:**
|
||||
- Database schema changes without automatic migration
|
||||
- Removed or renamed ENV variables
|
||||
- Changed API endpoints
|
||||
- Incompatible `.env` format changes
|
||||
- Loss of stored data after update
|
||||
|
||||
**Format for Breaking Changes:**
|
||||
|
||||
```markdown
|
||||
## ⚠️ BREAKING CHANGES - Please read before updating!
|
||||
|
||||
**Database migration required**: This update changes the database schema.
|
||||
Existing installations need to:
|
||||
1. Create backup of `data/` folder
|
||||
2. Stop containers
|
||||
3. Perform update
|
||||
4. If issues occur: Rollback using backup
|
||||
|
||||
**ENV variables changed**:
|
||||
- `OLD_VAR` was renamed to `NEW_VAR`
|
||||
- `REMOVED_VAR` is no longer supported
|
||||
|
||||
**Medication data**: Intake schedules with only one time entry will be automatically
|
||||
migrated. Please verify all times are correct after update.
|
||||
```
|
||||
|
||||
**What is NOT a Breaking Change:**
|
||||
- ✅ New optional columns with DEFAULT values
|
||||
- ✅ New ENV variables (with sensible defaults)
|
||||
- ✅ New features that don't affect existing data
|
||||
- ✅ Bug fixes that correct behavior
|
||||
|
||||
**Rule of thumb**: If a user can simply run `docker compose pull && docker compose up -d`
|
||||
without adjusting anything → Not a Breaking Change.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Backend Routes (`backend/src/routes/`)
|
||||
|
||||
@@ -3,8 +3,30 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.tsx'
|
||||
- '**.jsx'
|
||||
- 'backend/package.json'
|
||||
- 'backend/package-lock.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/codeql/**'
|
||||
- '.github/workflows/codeql.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.tsx'
|
||||
- '**.jsx'
|
||||
- 'backend/package.json'
|
||||
- 'backend/package-lock.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/codeql/**'
|
||||
- '.github/workflows/codeql.yml'
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
@@ -10,10 +10,38 @@ permissions:
|
||||
|
||||
jobs:
|
||||
# =============================================================================
|
||||
# Backend Tests
|
||||
# Detect which paths changed to skip unnecessary jobs
|
||||
# =============================================================================
|
||||
changes:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
- 'biome.json'
|
||||
- '.github/workflows/test.yml'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- 'biome.json'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
# =============================================================================
|
||||
# Backend Tests (skipped if no backend-related files changed)
|
||||
# =============================================================================
|
||||
backend-test:
|
||||
name: Backend Tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -53,10 +81,12 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# =============================================================================
|
||||
# Frontend Build Validation
|
||||
# Frontend Build Validation (skipped if no frontend-related files changed)
|
||||
# =============================================================================
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -91,16 +91,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
- name: Commit and push badge updates
|
||||
run: |
|
||||
git diff --quiet README.md || echo "changed=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push if changed
|
||||
if: steps.git-check.outputs.changed == 'true'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git commit -m "chore: update test count badges [skip ci]"
|
||||
git push
|
||||
if git diff --cached --quiet; then
|
||||
echo "No badge changes to commit"
|
||||
else
|
||||
git commit -m "chore: update test count badges [skip ci]"
|
||||
git push
|
||||
fi
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.3",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^10.0.1",
|
||||
"@fastify/cors": "^10.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+29
-314
@@ -1,322 +1,37 @@
|
||||
import { accessSync, constants, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
// Import utilities from db-utils (side-effect-free)
|
||||
import {
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDataDir,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
runAlterMigrations,
|
||||
runDrizzleMigrations,
|
||||
} from "./db-utils.js";
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
// Re-export all utilities so existing imports from client.ts keep working
|
||||
export {
|
||||
buildDbUrl,
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDataDir,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
runAlterMigrations,
|
||||
runDrizzleMigrations,
|
||||
} from "./db-utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Exported utility functions for testing
|
||||
// =============================================================================
|
||||
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = resolve(cwd, "data");
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(
|
||||
database: ReturnType<typeof drizzle>
|
||||
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
// If the error is "duplicate column", it means the schema is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns
|
||||
// We consider this a success with a warning, not a failure
|
||||
if (err.message?.includes("duplicate column")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
// 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) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tables that might be missing (silently fail if already exists)
|
||||
const createTableMigrations = [
|
||||
// Added in v1.3.x - refill history tracking
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup repair: fix orphaned dose tracking IDs from past schedule changes
|
||||
// =============================================================================
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
||||
* `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-"
|
||||
* instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs.
|
||||
*
|
||||
* This function is idempotent - safe to run on every startup.
|
||||
*/
|
||||
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||
);
|
||||
repaired = result.rowsAffected;
|
||||
} catch (e: any) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
|
||||
* This fixes dose IDs that became invalid when a medication's schedule was changed
|
||||
* BEFORE the on-edit migration (PR #103) was introduced.
|
||||
*
|
||||
* For each medication, generates all valid schedule dateOnlyMs values from each intake's
|
||||
* start date up to today, then checks all dose_tracking entries. Any dose whose timestamp
|
||||
* doesn't match a valid schedule date is remapped to the nearest valid date.
|
||||
*
|
||||
* This function is idempotent - safe to run on every startup.
|
||||
*/
|
||||
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
// Get all medications
|
||||
const medsResult = await client.execute(
|
||||
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
|
||||
);
|
||||
|
||||
if (medsResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
// Get all dose tracking entries
|
||||
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
|
||||
if (dosesResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
// Build a map of medId → dose entries for quick lookup
|
||||
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
|
||||
for (const row of dosesResult.rows) {
|
||||
const doseId = row.dose_id as string;
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({ id: row.id as number, doseId });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
for (const med of medsResult.rows) {
|
||||
const medId = med.id as number;
|
||||
const medDoses = dosesByMed.get(medId);
|
||||
if (!medDoses || medDoses.length === 0) continue;
|
||||
|
||||
// Parse intakes
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakes_json as string | null,
|
||||
{
|
||||
usageJson: (med.usage_json as string) || "[]",
|
||||
everyJson: (med.every_json as string) || "[]",
|
||||
startJson: (med.start_json as string) || "[]",
|
||||
},
|
||||
(med.intake_reminders_enabled as number) === 1
|
||||
);
|
||||
|
||||
if (intakes.length === 0) continue;
|
||||
|
||||
// For each intake index, build the set of valid dateOnlyMs values
|
||||
const validDatesByIntake = new Map<number, Set<number>>();
|
||||
for (let idx = 0; idx < intakes.length; idx++) {
|
||||
const intake = intakes[idx];
|
||||
const start = parseLocalDateTime(intake.start);
|
||||
const every = intake.every;
|
||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||
|
||||
const validDates = new Set<number>();
|
||||
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
|
||||
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
validDatesByIntake.set(idx, validDates);
|
||||
}
|
||||
|
||||
// Check each dose entry
|
||||
for (const dose of medDoses) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const intakeIdx = parseInt(parts[1], 10);
|
||||
const dateOnlyMs = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
|
||||
|
||||
const validDates = validDatesByIntake.get(intakeIdx);
|
||||
if (!validDates) continue; // Unknown intake index - skip
|
||||
|
||||
// Check if this dose's timestamp is valid
|
||||
if (validDates.has(dateOnlyMs)) continue; // Already valid - nothing to do
|
||||
|
||||
// Orphaned dose - find the nearest valid schedule date
|
||||
const intake = intakes[intakeIdx];
|
||||
if (!intake) continue;
|
||||
|
||||
const halfInterval = (intake.every * MS_PER_DAY) / 2;
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const validDate of validDates) {
|
||||
const dist = Math.abs(validDate - dateOnlyMs);
|
||||
if (dist < bestDist && dist <= halfInterval) {
|
||||
bestDist = dist;
|
||||
bestMatch = validDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch !== null) {
|
||||
// Rebuild dose ID with new timestamp, preserving person suffix
|
||||
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
|
||||
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
|
||||
|
||||
try {
|
||||
await client.execute({
|
||||
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
|
||||
args: [newDoseId, dose.id],
|
||||
});
|
||||
repaired++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Repair failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
|
||||
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// =============================================================================
|
||||
// Database initialization (runs on import)
|
||||
@@ -361,7 +76,7 @@ export const db = drizzle(client);
|
||||
// Auto-run migrations (self-healing database)
|
||||
async function runMigrations() {
|
||||
// Run drizzle-kit generated migrations
|
||||
console.log(`[DB] Running drizzle migrations from: ${migrationsFolder}`);
|
||||
console.log(`[DB] Running drizzle migrations...`);
|
||||
const migrateResult = await runDrizzleMigrations(db);
|
||||
if (!migrateResult.success) {
|
||||
console.error(`[DB] Migration error:`, migrateResult.error);
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Pure utility functions for database operations.
|
||||
* Separated from client.ts to allow importing without triggering
|
||||
* top-level database initialization side effects.
|
||||
*/
|
||||
|
||||
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Client } from "@libsql/client";
|
||||
import type { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
// =============================================================================
|
||||
// Path & Directory utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get the data directory path.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. DATA_DIR env var (set by docker-compose for containers)
|
||||
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
|
||||
* subdirectory → use ../data (project root's data folder)
|
||||
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
|
||||
*/
|
||||
export function getDataDir(cwd: string = process.cwd()): string {
|
||||
// Docker containers set DATA_DIR explicitly
|
||||
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
|
||||
|
||||
// Local dev: detect if we're in backend/ subdirectory of the monorepo
|
||||
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
|
||||
return resolve(cwd, "..", "data");
|
||||
}
|
||||
|
||||
// Default: data/ relative to cwd (running from project root)
|
||||
return resolve(cwd, "data");
|
||||
}
|
||||
|
||||
/** Build the database URL from a path */
|
||||
export function buildDbUrl(dbPath: string): string {
|
||||
return `file:${dbPath}`;
|
||||
}
|
||||
|
||||
/** Get data directory and database path */
|
||||
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
|
||||
const dataDir = getDataDir(cwd);
|
||||
const dbPath = resolve(dataDir, "medassist-ng.db");
|
||||
const url = buildDbUrl(dbPath);
|
||||
return { dataDir, dbPath, url };
|
||||
}
|
||||
|
||||
/** Ensure data directory exists and is writable */
|
||||
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if directory is writable
|
||||
accessSync(dataDir, constants.W_OK);
|
||||
|
||||
// Try to create a test file to verify write access
|
||||
const testFile = resolve(dataDir, ".write-test");
|
||||
writeFileSync(testFile, "test");
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Migration utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Run drizzle-kit migrations on the database */
|
||||
export async function runDrizzleMigrations(
|
||||
database: ReturnType<typeof drizzle>
|
||||
): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||
try {
|
||||
await migrate(database, { migrationsFolder });
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
// If the error is "duplicate column", it means the schema is already up-to-date
|
||||
// This happens when ALTER migrations in client.ts have already added the columns
|
||||
// We consider this a success with a warning, not a failure
|
||||
if (err.message?.includes("duplicate column")) {
|
||||
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
|
||||
}
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Run ALTER TABLE migrations for backward compatibility with older databases */
|
||||
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
// These add new columns to existing tables (silently fail if column already exists)
|
||||
const alterMigrations = [
|
||||
// Added in v1.x - repeat reminders and nagging settings
|
||||
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
// 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) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "duplicate column" errors - column already exists
|
||||
if (!e.message?.includes("duplicate column")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create tables that might be missing (silently fail if already exists)
|
||||
const createTableMigrations = [
|
||||
// Added in v1.3.x - refill history tracking
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const sql of createTableMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: any) {
|
||||
// Silently ignore "table already exists" errors
|
||||
if (!e.message?.includes("already exists")) {
|
||||
errors.push(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Ensure default user exists for auth-disabled mode */
|
||||
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
|
||||
if (authEnabled) {
|
||||
return false; // No default user needed
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.execute("SELECT id FROM users WHERE id = 1");
|
||||
if (result.rows.length === 0) {
|
||||
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
|
||||
return true; // Created
|
||||
}
|
||||
return false; // Already exists
|
||||
} catch (e: any) {
|
||||
console.error(`[DB] Error creating default user:`, e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup repair: fix orphaned dose tracking IDs from past schedule changes
|
||||
// =============================================================================
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
|
||||
* `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-"
|
||||
* instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs.
|
||||
*
|
||||
* This function is idempotent - safe to run on every startup.
|
||||
*/
|
||||
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
const result = await client.execute(
|
||||
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
|
||||
);
|
||||
repaired = result.rowsAffected;
|
||||
} catch (e: any) {
|
||||
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
|
||||
* This fixes dose IDs that became invalid when a medication's schedule was changed
|
||||
* BEFORE the on-edit migration (PR #103) was introduced.
|
||||
*
|
||||
* For each medication, generates all valid schedule dateOnlyMs values from each intake's
|
||||
* start date up to today, then checks all dose_tracking entries. Any dose whose timestamp
|
||||
* doesn't match a valid schedule date is remapped to the nearest valid date.
|
||||
*
|
||||
* This function is idempotent - safe to run on every startup.
|
||||
*/
|
||||
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
let repaired = 0;
|
||||
|
||||
try {
|
||||
// Get all medications
|
||||
const medsResult = await client.execute(
|
||||
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
|
||||
);
|
||||
|
||||
if (medsResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
// Get all dose tracking entries
|
||||
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
|
||||
if (dosesResult.rows.length === 0) return { repaired, errors };
|
||||
|
||||
// Build a map of medId → dose entries for quick lookup
|
||||
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
|
||||
for (const row of dosesResult.rows) {
|
||||
const doseId = row.dose_id as string;
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (Number.isNaN(medId)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({ id: row.id as number, doseId });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
for (const med of medsResult.rows) {
|
||||
const medId = med.id as number;
|
||||
const medDoses = dosesByMed.get(medId);
|
||||
if (!medDoses || medDoses.length === 0) continue;
|
||||
|
||||
// Parse intakes
|
||||
const intakes = parseIntakesJson(
|
||||
med.intakes_json as string | null,
|
||||
{
|
||||
usageJson: (med.usage_json as string) || "[]",
|
||||
everyJson: (med.every_json as string) || "[]",
|
||||
startJson: (med.start_json as string) || "[]",
|
||||
},
|
||||
(med.intake_reminders_enabled as number) === 1
|
||||
);
|
||||
|
||||
if (intakes.length === 0) continue;
|
||||
|
||||
// For each intake index, build the set of valid dateOnlyMs values
|
||||
const validDatesByIntake = new Map<number, Set<number>>();
|
||||
for (let idx = 0; idx < intakes.length; idx++) {
|
||||
const intake = intakes[idx];
|
||||
const start = parseLocalDateTime(intake.start);
|
||||
const every = intake.every;
|
||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||
|
||||
const validDates = new Set<number>();
|
||||
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
|
||||
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
validDatesByIntake.set(idx, validDates);
|
||||
}
|
||||
|
||||
// Check each dose entry
|
||||
for (const dose of medDoses) {
|
||||
const parts = dose.doseId.split("-");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const intakeIdx = parseInt(parts[1], 10);
|
||||
const dateOnlyMs = parseInt(parts[2], 10);
|
||||
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
|
||||
|
||||
const validDates = validDatesByIntake.get(intakeIdx);
|
||||
if (!validDates) continue; // Unknown intake index - skip
|
||||
|
||||
// Check if this dose's timestamp is valid
|
||||
if (validDates.has(dateOnlyMs)) continue; // Already valid - nothing to do
|
||||
|
||||
// Orphaned dose - find the nearest valid schedule date
|
||||
const intake = intakes[intakeIdx];
|
||||
if (!intake) continue;
|
||||
|
||||
const halfInterval = (intake.every * MS_PER_DAY) / 2;
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const validDate of validDates) {
|
||||
const dist = Math.abs(validDate - dateOnlyMs);
|
||||
if (dist < bestDist && dist <= halfInterval) {
|
||||
bestDist = dist;
|
||||
bestMatch = validDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch !== null) {
|
||||
// Rebuild dose ID with new timestamp, preserving person suffix
|
||||
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
|
||||
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
|
||||
|
||||
try {
|
||||
await client.execute({
|
||||
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
|
||||
args: [newDoseId, dose.id],
|
||||
});
|
||||
repaired++;
|
||||
} catch (e: any) {
|
||||
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`Repair failed: ${e.message}`);
|
||||
}
|
||||
|
||||
return { repaired, errors };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
@@ -5,7 +6,9 @@ import dotenv from "dotenv";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
|
||||
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -10,6 +10,7 @@ import sensible from "@fastify/sensible";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { migrationsReady } from "./db/client.js";
|
||||
import { getDataDir } from "./db/db-utils.js";
|
||||
import { env } from "./plugins/env.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
@@ -66,7 +67,7 @@ export async function createApp(options?: {
|
||||
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
|
||||
refreshTtlDays: options?.refreshTtlDays ?? 7,
|
||||
isProduction: options?.isProduction ?? false,
|
||||
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
|
||||
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
|
||||
};
|
||||
|
||||
const app = Fastify({
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
|
||||
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { refreshTokens, users } from "../db/schema.js";
|
||||
import { getAuthState, requireAuth } from "../plugins/auth.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -476,7 +477,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
// Save file
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
const imagesDir = path.join(process.cwd(), "data", "images");
|
||||
const imagesDir = path.join(getDataDir(), "images");
|
||||
await fs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
@@ -523,7 +524,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
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));
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
@@ -556,7 +557,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
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));
|
||||
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.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(getDataDir(), "images");
|
||||
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
|
||||
@@ -5,13 +5,14 @@ import { and, eq, like } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(process.cwd(), "data/images");
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// New intake schema with per-intake takenBy
|
||||
const intakeSchema = z.object({
|
||||
@@ -284,6 +285,17 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const startJson = JSON.stringify(intakes.map((s) => s.start));
|
||||
const takenByJson = JSON.stringify(takenBy || []);
|
||||
|
||||
// If stock-defining fields changed, reset stockAdjustment so the new
|
||||
// base stock reflects actual inventory. This prevents the old
|
||||
// correction offset from skewing the total after an edit.
|
||||
const stockFieldsChanged =
|
||||
existing.packCount !== packCount ||
|
||||
existing.blistersPerPack !== blistersPerPack ||
|
||||
existing.pillsPerBlister !== pillsPerBlister ||
|
||||
(existing.looseTablets ?? 0) !== (looseTablets ?? 0);
|
||||
|
||||
const stockResetFields = stockFieldsChanged ? { stockAdjustment: 0, lastStockCorrectionAt: new Date() } : {};
|
||||
|
||||
const result = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
@@ -306,6 +318,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
everyJson,
|
||||
startJson,
|
||||
updatedAt: new Date(),
|
||||
...stockResetFields,
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
@@ -668,10 +681,19 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
|
||||
const effectiveStart = Math.max(blisterStart.getTime(), stockCorrectionCutoff);
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
// After a stock correction, start counting from the NEXT scheduled
|
||||
// dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart.getTime();
|
||||
}
|
||||
if (effectiveStart > now.getTime()) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
|
||||
// Get the people for this intake (from intakes array or medication takenBy)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
||||
import { and, eq, gte, lte } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { doseTracking, medications } from "../db/schema.js";
|
||||
import { getDateLocale, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
@@ -25,7 +26,7 @@ import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-s
|
||||
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
|
||||
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
|
||||
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
|
||||
|
||||
function loadIntakeReminderState(): IntakeReminderState {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
import { medications, userSettings } from "../db/schema.js";
|
||||
import { getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
|
||||
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
|
||||
|
||||
const reminderStateFile = resolve(process.cwd(), "data", "reminder-state.json");
|
||||
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
|
||||
|
||||
function loadReminderState(): ReminderState {
|
||||
try {
|
||||
|
||||
@@ -5,19 +5,20 @@ import { fileURLToPath } from "node:url";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Import the exported utility functions from client.ts
|
||||
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
|
||||
import {
|
||||
buildDbUrl,
|
||||
ensureDataDirectory,
|
||||
ensureDefaultUser,
|
||||
getDataDir,
|
||||
getDbPaths,
|
||||
repairOrphanedDoseIds,
|
||||
repairTrailingHyphenDoseIds,
|
||||
runAlterMigrations,
|
||||
runDrizzleMigrations,
|
||||
} from "../db/client.js";
|
||||
} from "../db/db-utils.js";
|
||||
|
||||
// Import the exported utility functions from migrate.ts
|
||||
import { executeMigration, getStatementPreview, splitSQLStatements } from "../db/migrate.js";
|
||||
@@ -144,15 +145,78 @@ describe("Database Client Utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDir", () => {
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("should use DATA_DIR env var when set (Docker)", () => {
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
expect(getDataDir()).toBe("/app/data");
|
||||
});
|
||||
|
||||
it("should resolve relative DATA_DIR to absolute", () => {
|
||||
process.env.DATA_DIR = "../data";
|
||||
const result = getDataDir();
|
||||
expect(result).not.toContain("..");
|
||||
expect(result).toMatch(/\/data$/);
|
||||
});
|
||||
|
||||
it("should detect monorepo and use ../data when in backend/ subdir", () => {
|
||||
delete process.env.DATA_DIR;
|
||||
// Tests run from backend/ which has ../docker-compose.yml
|
||||
const result = getDataDir();
|
||||
// Should resolve to the project root's data/ folder, not backend/data/
|
||||
expect(result).toMatch(/\/data$/);
|
||||
expect(result).not.toMatch(/backend\/data$/);
|
||||
});
|
||||
|
||||
it("should fall back to cwd/data when not in monorepo", () => {
|
||||
delete process.env.DATA_DIR;
|
||||
// Use a directory that has no ../docker-compose.yml
|
||||
expect(getDataDir("/tmp")).toBe("/tmp/data");
|
||||
});
|
||||
|
||||
it("should prefer DATA_DIR over monorepo detection", () => {
|
||||
process.env.DATA_DIR = "/override/data";
|
||||
expect(getDataDir("/app")).toBe("/override/data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDbPaths", () => {
|
||||
it("should return correct paths based on cwd", () => {
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = originalDataDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("should return correct paths with DATA_DIR set", () => {
|
||||
process.env.DATA_DIR = "/app/data";
|
||||
const paths = getDbPaths("/app");
|
||||
expect(paths.dataDir).toBe("/app/data");
|
||||
expect(paths.dbPath).toBe("/app/data/medassist-ng.db");
|
||||
expect(paths.url).toBe("file:/app/data/medassist-ng.db");
|
||||
});
|
||||
|
||||
it("should return correct paths without DATA_DIR in non-monorepo dir", () => {
|
||||
delete process.env.DATA_DIR;
|
||||
const paths = getDbPaths("/tmp");
|
||||
expect(paths.dataDir).toBe("/tmp/data");
|
||||
expect(paths.dbPath).toBe("/tmp/data/medassist-ng.db");
|
||||
});
|
||||
|
||||
it("should use process.cwd() by default", () => {
|
||||
delete process.env.DATA_DIR;
|
||||
const paths = getDbPaths();
|
||||
expect(paths.dataDir).toContain("data");
|
||||
expect(paths.dbPath).toContain("medassist-ng.db");
|
||||
|
||||
@@ -1730,6 +1730,304 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real Stock Correction (PATCH /medications/:id/stock-adjustment) Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Real /medications/:id/stock-adjustment routes", () => {
|
||||
it("should update stockAdjustment and lastStockCorrectionAt", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Stock Correction Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 14,
|
||||
pillsPerBlister: 14,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Correct stock: set adjustment to -83 (196 base - 83 = 113 pills)
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -83 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.stockAdjustment).toBe(-83);
|
||||
expect(data.lastStockCorrectionAt).toBeTruthy();
|
||||
expect(data.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should persist stockAdjustment in GET /medications", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Persist Stock Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Apply stock correction
|
||||
await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -7 },
|
||||
});
|
||||
|
||||
// Verify via GET
|
||||
const getResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const meds = getResponse.json();
|
||||
const med = meds.find((m: any) => m.id === medId);
|
||||
expect(med).toBeDefined();
|
||||
expect(med.stockAdjustment).toBe(-7);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not reset stockAdjustment when editing medication via PUT", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Keep Adjustment Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Set stock adjustment
|
||||
await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -5 },
|
||||
});
|
||||
|
||||
// Edit the medication (change name) - should preserve stockAdjustment
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Renamed Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify stockAdjustment is preserved
|
||||
const getResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/medications",
|
||||
});
|
||||
const med = getResponse.json().find((m: any) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
|
||||
it("should return 400 for non-numeric stockAdjustment", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Bad Adjustment Med",
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: "not-a-number" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent medication", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/medications/99999/stock-adjustment",
|
||||
payload: { stockAdjustment: 5 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid medication id", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/medications/invalid/stock-adjustment",
|
||||
payload: { stockAdjustment: 5 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("should reset stockAdjustment when stock fields change via PUT", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Reset Adj Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Set stock adjustment to -10
|
||||
await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -10 },
|
||||
});
|
||||
|
||||
// Verify adjustment is set
|
||||
let getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
let med = getMeds.json().find((m: any) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(-10);
|
||||
|
||||
// Edit medication with CHANGED stock fields (packCount 1 → 2)
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Reset Adj Med",
|
||||
packCount: 2,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// stockAdjustment should be reset to 0
|
||||
getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
med = getMeds.json().find((m: any) => m.id === medId);
|
||||
expect(med.stockAdjustment).toBe(0);
|
||||
expect(med.lastStockCorrectionAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should preserve stockAdjustment when only non-stock fields change via PUT", async () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Preserve Adj Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Set stock adjustment
|
||||
await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -5 },
|
||||
});
|
||||
|
||||
// Edit only non-stock fields (name, notes)
|
||||
await app.inject({
|
||||
method: "PUT",
|
||||
url: `/medications/${medId}`,
|
||||
payload: {
|
||||
name: "Renamed Preserve Med",
|
||||
notes: "Updated notes",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
// stockAdjustment should be preserved
|
||||
const getMeds = await app.inject({ method: "GET", url: "/medications" });
|
||||
const med = getMeds.json().find((m: any) => m.id === medId);
|
||||
expect(med.name).toBe("Renamed Preserve Med");
|
||||
expect(med.stockAdjustment).toBe(-5);
|
||||
});
|
||||
|
||||
it("should not count phantom consumption in planner after stock correction", async () => {
|
||||
// Create medication: 1 pack × 14 blisters × 14 pills = 196 pills total
|
||||
// Schedule: 1 pill daily starting far in the past
|
||||
const farPast = new Date("2024-01-01T08:00:00.000Z");
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Planner Phantom Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 14,
|
||||
pillsPerBlister: 14,
|
||||
looseTablets: 0,
|
||||
blisters: [{ usage: 1, every: 1, start: farPast.toISOString() }],
|
||||
},
|
||||
});
|
||||
const medId = createResponse.json().id;
|
||||
|
||||
// Correct stock to 113 pills (196 base - 83 = 113)
|
||||
await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/medications/${medId}/stock-adjustment`,
|
||||
payload: { stockAdjustment: -83 },
|
||||
});
|
||||
|
||||
// Query planner immediately - stock should be ~113 (not reduced by phantom dose)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: tomorrow.toISOString(),
|
||||
endDate: nextWeek.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
const med = data.find((m: any) => m.medicationId === medId);
|
||||
expect(med).toBeDefined();
|
||||
// Total should be very close to 113 (not 112 or lower from phantom consumption)
|
||||
// Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass)
|
||||
expect(med.totalPills).toBeGreaterThanOrEqual(112);
|
||||
expect(med.totalPills).toBeLessThanOrEqual(113);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real Export/Import Routes Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { CookieSerializeOptions } from "@fastify/cookie";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
|
||||
/**
|
||||
* Parse comma-separated CORS origins string
|
||||
@@ -81,8 +82,7 @@ export function buildAppConfig(options: AppConfigOptions): AppConfig {
|
||||
* Ensure images directory exists
|
||||
*/
|
||||
export function ensureImagesDirectory(cwd?: string): string {
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
const imagesDir = resolve(getDataDir(cwd), "images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
security_opt:
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
@@ -34,6 +35,8 @@ services:
|
||||
frontend:
|
||||
image: ghcr.io/danielvolz/medassist-ng-frontend:latest
|
||||
container_name: medassist-ng-frontend
|
||||
environment:
|
||||
- BACKEND_URL=backend:3000
|
||||
ports:
|
||||
- "4174:8080"
|
||||
networks:
|
||||
@@ -49,6 +52,7 @@ services:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
- /var/cache/nginx:noexec,nosuid,size=64m
|
||||
- /var/run:noexec,nosuid,size=64m
|
||||
- /etc/nginx/conf.d:noexec,nosuid,size=1m,uid=101,gid=101
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
|
||||
+4
-3
@@ -32,8 +32,9 @@ RUN npm run build
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runner
|
||||
|
||||
# Copy custom nginx config (must listen on 8080, not 80)
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Copy custom nginx config as template for envsubst processing
|
||||
# nginx-unprivileged automatically substitutes env vars in .template files
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Copy built static files with correct ownership (nginx user = uid 101)
|
||||
COPY --from=builder --chown=101:101 /app/dist /usr/share/nginx/html
|
||||
@@ -44,5 +45,5 @@ EXPOSE 8080
|
||||
# Already runs as non-root (nginx user, uid 101)
|
||||
USER nginx
|
||||
|
||||
# Start nginx
|
||||
# Start nginx (entrypoint processes templates automatically)
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+8
-1
@@ -20,7 +20,14 @@ server {
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://medassist-ng-backend:3000/;
|
||||
# Use variable for runtime DNS resolution (nginx resolves at startup by default)
|
||||
# Docker embedded DNS (127.0.0.11) with 10s cache
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $backend_upstream ${BACKEND_URL};
|
||||
|
||||
# Strip /api prefix (nginx doesn't auto-rewrite when using variables in proxy_pass)
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
proxy_pass http://$backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
Generated
+12
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.3",
|
||||
"dependencies": {
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
@@ -133,7 +133,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -655,7 +654,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -699,7 +697,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1647,7 +1644,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -1739,7 +1737,6 @@
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1751,7 +1748,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -1958,6 +1954,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1968,6 +1965,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -2054,7 +2052,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2238,7 +2235,8 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.267",
|
||||
@@ -2468,7 +2466,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.10"
|
||||
},
|
||||
@@ -2558,7 +2555,6 @@
|
||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -2647,6 +2643,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -2796,7 +2793,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2886,6 +2882,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -2910,7 +2907,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2923,7 +2919,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2963,7 +2958,8 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
@@ -3277,7 +3273,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3323,7 +3318,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -3399,7 +3393,6 @@
|
||||
"integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.17",
|
||||
"@vitest/mocker": "4.0.17",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.7.1",
|
||||
"version": "1.8.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FRONTEND_VERSION, GITHUB_URL } from "../App";
|
||||
|
||||
interface UpdateCheckResult {
|
||||
status: "checking" | "up-to-date" | "update-available" | "error";
|
||||
status: "up-to-date" | "update-available" | "error";
|
||||
latestVersion?: string;
|
||||
lastChecked?: string;
|
||||
}
|
||||
@@ -15,19 +15,13 @@ interface AboutModalProps {
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResult | null>(null);
|
||||
|
||||
// Fetch backend version and cached update result on mount
|
||||
// Load cached update check result on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Fetch backend version
|
||||
fetch("/api/health")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setBackendVersion(data.version || "unknown"))
|
||||
.catch(() => setBackendVersion("unknown"));
|
||||
|
||||
// Load cached update check result
|
||||
const cached = sessionStorage.getItem("updateCheckResult");
|
||||
if (cached) {
|
||||
@@ -43,9 +37,13 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
}, [isOpen]);
|
||||
|
||||
async function checkForUpdates() {
|
||||
setUpdateCheckResult({ status: "checking" });
|
||||
setIsChecking(true);
|
||||
const minDelay = new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`);
|
||||
const [res] = await Promise.all([
|
||||
fetch(`https://api.github.com/repos/DanielVolz/medassist-ng/releases/latest`),
|
||||
minDelay,
|
||||
]);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const data = await res.json();
|
||||
const latestVersion = (data.tag_name || "").replace(/^v/, "");
|
||||
@@ -61,6 +59,8 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
sessionStorage.setItem("updateCheckResult", JSON.stringify(result));
|
||||
} catch {
|
||||
setUpdateCheckResult({ status: "error" });
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,32 +74,27 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
</button>
|
||||
<div className="about-header">
|
||||
<div className="about-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z" />
|
||||
<path d="M12 8v4l2.5 2.5" />
|
||||
<path d="M9 2h6M12 2v2" />
|
||||
</svg>
|
||||
<img src="/favicon.svg" alt="MedAssist-ng" />
|
||||
</div>
|
||||
<h2>{t("about.appName", "MedAssist-ng")}</h2>
|
||||
<p className="about-tagline">{t("about.description", "Personal medication tracking and reminder app")}</p>
|
||||
</div>
|
||||
<div className="about-versions">
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t("about.frontendVersion", "Frontend")}</span>
|
||||
<span className="about-version-value">{FRONTEND_VERSION}</span>
|
||||
</div>
|
||||
<div className="about-version-row">
|
||||
<span className="about-version-label">{t("about.backendVersion", "Backend")}</span>
|
||||
<span className="about-version-value">{backendVersion || "..."}</span>
|
||||
<span className="about-version-label">{t("about.version", "Version")}</span>
|
||||
<a
|
||||
href={`${GITHUB_URL}/releases/tag/v${FRONTEND_VERSION}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="about-version-value about-version-link"
|
||||
>
|
||||
{FRONTEND_VERSION}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-update-section">
|
||||
<button
|
||||
className="about-update-btn"
|
||||
onClick={checkForUpdates}
|
||||
disabled={updateCheckResult?.status === "checking"}
|
||||
>
|
||||
{updateCheckResult?.status === "checking" ? (
|
||||
<button className="about-update-btn" onClick={checkForUpdates} disabled={isChecking}>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<span className="spinner-small"></span>
|
||||
{t("about.checking", "Checking...")}
|
||||
@@ -116,7 +111,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{updateCheckResult && updateCheckResult.status !== "checking" && (
|
||||
{updateCheckResult && (
|
||||
<div className={`about-update-result ${updateCheckResult.status}`}>
|
||||
{updateCheckResult.status === "up-to-date" && (
|
||||
<span className="update-status-text">✓ {t("about.upToDate", "You are up to date!")}</span>
|
||||
|
||||
@@ -351,38 +351,47 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const groupedSchedule = useMemo(() => {
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
|
||||
schedule.events.slice(0, 2000).forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? {
|
||||
dateStr: event.dateStr,
|
||||
date: new Date(event.when),
|
||||
isPast: event.isPast,
|
||||
meds: new Map(),
|
||||
};
|
||||
const medEntry = day.meds.get(event.medName) ?? {
|
||||
medName: event.medName,
|
||||
total: 0,
|
||||
doses: [],
|
||||
lastWhen: event.when,
|
||||
};
|
||||
medEntry.total += event.usage;
|
||||
medEntry.doses.push({
|
||||
id: event.id,
|
||||
timeStr: event.timeStr,
|
||||
when: event.when,
|
||||
usage: event.usage,
|
||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||
// Limit past events to scheduleDays window to avoid overwhelming the UI.
|
||||
// Without this, medications with start dates far in the past generate thousands
|
||||
// of events that fill the display budget and push out today/future events.
|
||||
const pastCutoff = new Date();
|
||||
pastCutoff.setDate(pastCutoff.getDate() - scheduleDays);
|
||||
pastCutoff.setHours(0, 0, 0, 0);
|
||||
const pastCutoffMs = pastCutoff.getTime();
|
||||
schedule.events
|
||||
.filter((e) => !e.isPast || e.when >= pastCutoffMs)
|
||||
.forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? {
|
||||
dateStr: event.dateStr,
|
||||
date: new Date(event.when),
|
||||
isPast: event.isPast,
|
||||
meds: new Map(),
|
||||
};
|
||||
const medEntry = day.meds.get(event.medName) ?? {
|
||||
medName: event.medName,
|
||||
total: 0,
|
||||
doses: [],
|
||||
lastWhen: event.when,
|
||||
};
|
||||
medEntry.total += event.usage;
|
||||
medEntry.doses.push({
|
||||
id: event.id,
|
||||
timeStr: event.timeStr,
|
||||
when: event.when,
|
||||
usage: event.usage,
|
||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||
});
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
return Array.from(days.values()).map((d) => ({
|
||||
dateStr: d.dateStr,
|
||||
date: d.date,
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values()),
|
||||
}));
|
||||
}, [schedule.events]);
|
||||
}, [schedule.events, scheduleDays]);
|
||||
|
||||
const pastDays = useMemo(() => groupedSchedule.filter((d) => d.isPast), [groupedSchedule]);
|
||||
|
||||
|
||||
@@ -474,8 +474,6 @@
|
||||
"appName": "MedAssist-ng",
|
||||
"description": "Open-Source Medikamentenverwaltung und Planungsanwendung für selbst gehostete Umgebungen.",
|
||||
"version": "Version",
|
||||
"frontend": "Frontend",
|
||||
"backend": "Backend",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"checking": "Prüfe...",
|
||||
"upToDate": "Du bist auf dem neuesten Stand!",
|
||||
|
||||
@@ -474,8 +474,6 @@
|
||||
"appName": "MedAssist-ng",
|
||||
"description": "Open-source medication tracking and planning application for self-hosted environments.",
|
||||
"version": "Version",
|
||||
"frontend": "Frontend",
|
||||
"backend": "Backend",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"checking": "Checking...",
|
||||
"upToDate": "You're up to date!",
|
||||
|
||||
+17
-15
@@ -4551,18 +4551,12 @@ h3 .reminder-icon.info-tooltip {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1rem;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(var(--accent-rgb, 59, 130, 246), 0.25);
|
||||
}
|
||||
|
||||
.about-logo svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
stroke: white;
|
||||
.about-logo img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.about-header h2 {
|
||||
@@ -4586,15 +4580,12 @@ h3 .reminder-icon.info-tooltip {
|
||||
|
||||
.about-version-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.about-version-row:not(:last-child) {
|
||||
border-bottom: 1px dashed var(--border-secondary);
|
||||
}
|
||||
|
||||
.about-version-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -4610,9 +4601,20 @@ h3 .reminder-icon.info-tooltip {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
a.about-version-link {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: text-decoration 0.15s ease;
|
||||
}
|
||||
|
||||
a.about-version-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.about-update-section {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
min-height: 148px;
|
||||
}
|
||||
|
||||
.about-update-btn {
|
||||
|
||||
@@ -16,10 +16,6 @@ describe("AboutModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ version: "1.0.0" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when not open", () => {
|
||||
@@ -65,8 +61,10 @@ describe("AboutModal", () => {
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("fetches backend version on open", async () => {
|
||||
it("renders version as link to GitHub release", () => {
|
||||
render(<AboutModal {...defaultProps} />);
|
||||
expect(fetch).toHaveBeenCalledWith("/api/health");
|
||||
const versionLink = screen.getByText("1.0.0").closest("a");
|
||||
expect(versionLink).toHaveAttribute("href", "https://github.com/test/repo/releases/tag/v1.0.0");
|
||||
expect(versionLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,6 +366,123 @@ describe("calculateCoverage", () => {
|
||||
expect(result.all).toHaveLength(1);
|
||||
// Daily rate should be doubled for 2 people
|
||||
});
|
||||
|
||||
it("stock correction immediately reflects new stock without phantom consumption", () => {
|
||||
// BUG: After a stock correction of +1 pill, the coverage calculation
|
||||
// immediately consumed 1 dose (due to the +1 in occurrences formula),
|
||||
// making the correction appear to have no effect.
|
||||
//
|
||||
// Scenario: User has 112 pills, corrects to 113 (+1 pill).
|
||||
// Expected: medsLeft = 113 immediately after correction.
|
||||
// Bug: medsLeft stayed at 112 because 1 dose was counted as consumed.
|
||||
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "TestMed",
|
||||
packCount: 1,
|
||||
blistersPerPack: 14,
|
||||
pillsPerBlister: 14,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: -83, // 196 - 83 = 113 pills
|
||||
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||
takenBy: [],
|
||||
blisters: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-01-01T08:00:00",
|
||||
},
|
||||
],
|
||||
updatedAt: correctionTime.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// Calculate coverage immediately after correction (same second)
|
||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
// getMedTotal = 1*14*14 + 0 + (-83) = 113
|
||||
// Consumed since correction should be 0 (not 1!)
|
||||
expect(result.all[0].medsLeft).toBe(113);
|
||||
});
|
||||
|
||||
it("stock correction with dose tracking data also reflects correctly", () => {
|
||||
// When the user has dose tracking data, the actualConsumed path is used.
|
||||
// Verify that no phantom dose is generated right after a stock correction.
|
||||
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||
const march14 = new Date("2024-03-14T00:00:00").getTime();
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "TestMed",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: -7, // 30 - 7 = 23 pills
|
||||
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||
takenBy: [],
|
||||
blisters: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-01T08:00:00",
|
||||
},
|
||||
],
|
||||
updatedAt: correctionTime.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// User has tracked a dose yesterday (before the correction)
|
||||
const takenDoses = new Set([`1-0-${march14}`]);
|
||||
|
||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
// getMedTotal = 30 - 7 = 23.
|
||||
// The taken dose from yesterday should NOT be counted (it's before the correction).
|
||||
// No new doses should exist since the correction just happened.
|
||||
expect(result.all[0].medsLeft).toBe(23);
|
||||
});
|
||||
|
||||
it("stock correction consumption resumes after one full period", () => {
|
||||
// After 1 day (for daily medication), the next dose should be consumed.
|
||||
// Set system time to 1 day + 1 hour after correction.
|
||||
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
||||
vi.setSystemTime(new Date("2024-03-15T13:00:00Z")); // 25 hours after correction
|
||||
|
||||
const meds: Medication[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "TestMed",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 30,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: -7, // 30 - 7 = 23 pills
|
||||
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||
takenBy: [],
|
||||
blisters: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2024-03-01T08:00:00",
|
||||
},
|
||||
],
|
||||
updatedAt: correctionTime.toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||
|
||||
expect(result.all).toHaveLength(1);
|
||||
// After 1 full period since correction, 1 dose should be consumed.
|
||||
// medsLeft = 23 - 1 = 22
|
||||
expect(result.all[0].medsLeft).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStockStatus", () => {
|
||||
@@ -1197,3 +1314,251 @@ describe("expandDoseIds", () => {
|
||||
expect(result).toEqual(["1-0-1729123200000"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("past schedule windowing", () => {
|
||||
// Reproduces the bug where daily medications with far-past start dates
|
||||
// would fill all 2000 event slots, pushing today/future events out.
|
||||
// The fix replaces slice(0, 2000) with a time-based window filter.
|
||||
|
||||
function buildGroupedScheduleFromEvents(
|
||||
events: Array<{
|
||||
dateStr: string;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
medName: string;
|
||||
id: string;
|
||||
usage: number;
|
||||
takenBy: string | null;
|
||||
}>,
|
||||
scheduleDays: number
|
||||
) {
|
||||
// Mirror the fixed groupedSchedule logic from AppContext.tsx
|
||||
const pastCutoff = new Date();
|
||||
pastCutoff.setDate(pastCutoff.getDate() - scheduleDays);
|
||||
pastCutoff.setHours(0, 0, 0, 0);
|
||||
const pastCutoffMs = pastCutoff.getTime();
|
||||
|
||||
type DayMedEntry = {
|
||||
medName: string;
|
||||
total: number;
|
||||
doses: Array<{ id: string; takenBy: string[] }>;
|
||||
lastWhen: number;
|
||||
};
|
||||
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, DayMedEntry> }>();
|
||||
|
||||
events
|
||||
.filter((e) => !e.isPast || e.when >= pastCutoffMs)
|
||||
.forEach((event) => {
|
||||
const day = days.get(event.dateStr) ?? {
|
||||
dateStr: event.dateStr,
|
||||
date: new Date(event.when),
|
||||
isPast: event.isPast,
|
||||
meds: new Map(),
|
||||
};
|
||||
const medEntry = day.meds.get(event.medName) ?? {
|
||||
medName: event.medName,
|
||||
total: 0,
|
||||
doses: [],
|
||||
lastWhen: event.when,
|
||||
};
|
||||
medEntry.total += event.usage;
|
||||
medEntry.doses.push({
|
||||
id: event.id,
|
||||
takenBy: event.takenBy ? [event.takenBy] : [],
|
||||
});
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
});
|
||||
|
||||
return Array.from(days.values()).map((d) => ({
|
||||
dateStr: d.dateStr,
|
||||
date: d.date,
|
||||
isPast: d.isPast,
|
||||
meds: Array.from(d.meds.values()),
|
||||
}));
|
||||
}
|
||||
|
||||
it("includes daily meds within the scheduleDays window, not just weekly meds", () => {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const events: Array<{
|
||||
dateStr: string;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
medName: string;
|
||||
id: string;
|
||||
usage: number;
|
||||
takenBy: string | null;
|
||||
}> = [];
|
||||
|
||||
// Simulate daily med starting 400 days ago (way beyond any window)
|
||||
for (let i = 400; i >= 1; i--) {
|
||||
const d = new Date(todayStart);
|
||||
d.setDate(d.getDate() - i);
|
||||
events.push({
|
||||
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: d.getTime(),
|
||||
isPast: true,
|
||||
medName: "DailyMed",
|
||||
id: `1-0-${d.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate weekly Friday med starting 400 days ago
|
||||
for (let i = 400; i >= 1; i--) {
|
||||
const d = new Date(todayStart);
|
||||
d.setDate(d.getDate() - i);
|
||||
if (d.getDay() !== 5) continue; // Only Fridays
|
||||
events.push({
|
||||
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: d.getTime(),
|
||||
isPast: true,
|
||||
medName: "WeeklyMed",
|
||||
id: `2-0-${d.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Add today event
|
||||
events.push({
|
||||
dateStr: todayStart.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: todayStart.getTime(),
|
||||
isPast: false,
|
||||
medName: "DailyMed",
|
||||
id: `1-0-${todayStart.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: "Daniel",
|
||||
});
|
||||
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
const grouped = buildGroupedScheduleFromEvents(events, 30);
|
||||
const pastDays = grouped.filter((d) => d.isPast);
|
||||
const todayDays = grouped.filter((d) => !d.isPast);
|
||||
|
||||
// Past days should contain at most 30 days (scheduleDays window)
|
||||
expect(pastDays.length).toBeLessThanOrEqual(30);
|
||||
expect(pastDays.length).toBeGreaterThan(0);
|
||||
|
||||
// Today should be present (not pushed out by past events)
|
||||
expect(todayDays.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Past days should include DailyMed (within the 30-day window)
|
||||
const pastDaysWithDailyMed = pastDays.filter((d) => d.meds.some((m) => m.medName === "DailyMed"));
|
||||
expect(pastDaysWithDailyMed.length).toBeGreaterThan(0);
|
||||
|
||||
// Past days should NOT only be Fridays (the bug)
|
||||
const pastDateDays = pastDays.map((d) => d.date.getDay());
|
||||
const uniqueDaysOfWeek = new Set(pastDateDays);
|
||||
expect(uniqueDaysOfWeek.size).toBeGreaterThan(1); // Multiple days of week, not just Friday
|
||||
});
|
||||
|
||||
it("old slice(0,2000) would have cut off today for large datasets", () => {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const events: Array<{
|
||||
dateStr: string;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
medName: string;
|
||||
id: string;
|
||||
usage: number;
|
||||
takenBy: string | null;
|
||||
}> = [];
|
||||
|
||||
// Generate 3 daily meds × 2 intakes × 400 days = 2400 past events (> 2000 limit)
|
||||
for (let medIdx = 0; medIdx < 3; medIdx++) {
|
||||
for (let intakeIdx = 0; intakeIdx < 2; intakeIdx++) {
|
||||
for (let i = 400; i >= 1; i--) {
|
||||
const d = new Date(todayStart);
|
||||
d.setDate(d.getDate() - i);
|
||||
events.push({
|
||||
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: d.getTime() + intakeIdx * 3600000, // offset intakes by 1 hour
|
||||
isPast: true,
|
||||
medName: `Med${medIdx}`,
|
||||
id: `${medIdx}-${intakeIdx}-${d.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Today event
|
||||
events.push({
|
||||
dateStr: todayStart.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: todayStart.getTime(),
|
||||
isPast: false,
|
||||
medName: `Med${medIdx}`,
|
||||
id: `${medIdx}-0-${todayStart.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
}
|
||||
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
// OLD behavior: slice(0, 2000) would have cut off today
|
||||
const oldSliced = events.slice(0, 2000);
|
||||
const todayInOld = oldSliced.filter((e) => !e.isPast);
|
||||
expect(todayInOld.length).toBe(0); // Today events are gone!
|
||||
|
||||
// NEW behavior: time-based window keeps today
|
||||
const grouped = buildGroupedScheduleFromEvents(events, 30);
|
||||
const todayDays = grouped.filter((d) => !d.isPast);
|
||||
expect(todayDays.length).toBeGreaterThanOrEqual(1);
|
||||
// All 3 meds should appear in today
|
||||
const todayMeds = todayDays[0]?.meds.map((m) => m.medName).sort();
|
||||
expect(todayMeds).toEqual(["Med0", "Med1", "Med2"]);
|
||||
});
|
||||
|
||||
it("respects scheduleDays parameter for past window size", () => {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const events: Array<{
|
||||
dateStr: string;
|
||||
when: number;
|
||||
isPast: boolean;
|
||||
medName: string;
|
||||
id: string;
|
||||
usage: number;
|
||||
takenBy: string | null;
|
||||
}> = [];
|
||||
|
||||
// Daily med for 200 days
|
||||
for (let i = 200; i >= 1; i--) {
|
||||
const d = new Date(todayStart);
|
||||
d.setDate(d.getDate() - i);
|
||||
events.push({
|
||||
dateStr: d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short" }),
|
||||
when: d.getTime(),
|
||||
isPast: true,
|
||||
medName: "TestMed",
|
||||
id: `1-0-${d.getTime()}`,
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
}
|
||||
events.sort((a, b) => a.when - b.when);
|
||||
|
||||
// With 30 days window
|
||||
const grouped30 = buildGroupedScheduleFromEvents(events, 30);
|
||||
const past30 = grouped30.filter((d) => d.isPast);
|
||||
expect(past30.length).toBeLessThanOrEqual(30);
|
||||
|
||||
// With 90 days window
|
||||
const grouped90 = buildGroupedScheduleFromEvents(events, 90);
|
||||
const past90 = grouped90.filter((d) => d.isPast);
|
||||
expect(past90.length).toBeLessThanOrEqual(90);
|
||||
expect(past90.length).toBeGreaterThan(past30.length);
|
||||
|
||||
// With 180 days window
|
||||
const grouped180 = buildGroupedScheduleFromEvents(events, 180);
|
||||
const past180 = grouped180.filter((d) => d.isPast);
|
||||
expect(past180.length).toBeLessThanOrEqual(180);
|
||||
expect(past180.length).toBeGreaterThan(past90.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,9 +126,18 @@ export function calculateCoverage(
|
||||
// but also account for manual corrections (doses marked as not taken)
|
||||
blisters.forEach((s, blisterIdx) => {
|
||||
const blisterStart = new Date(s.start).getTime();
|
||||
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||
|
||||
// After a stock correction, start counting consumption from the NEXT
|
||||
// scheduled dose, because the user's pill count already reflects all
|
||||
// consumption up to the correction time.
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
effectiveStart = stockCorrectionCutoff + period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
|
||||
Reference in New Issue
Block a user