Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2e445398 | |||
| 61b8812808 | |||
| f7838bd919 | |||
| b0fd3f4187 |
@@ -179,8 +179,8 @@ The version number is displayed in the **About modal** (Settings → About) as a
|
|||||||
|
|
||||||
### After Tagging
|
### After Tagging
|
||||||
|
|
||||||
- The `docker-build.yml` workflow automatically builds and pushes Docker images to GHCR.
|
- The `docker-build.yml` workflow automatically builds and pushes Docker images to GHCR with both versioned tags (`1.8.7`, `1.8`) and `latest`.
|
||||||
- The `version-bump.yml` workflow automatically updates `package.json` versions if needed.
|
- The `update-test-badges.yml` workflow runs automatically after a successful Docker build to update test count badges in the README.
|
||||||
- Track progress: `https://github.com/DanielVolz/medassist-ng/actions`
|
- Track progress: `https://github.com/DanielVolz/medassist-ng/actions`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -138,11 +138,16 @@ Push to main / Tag created
|
|||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ docker-build.yml │
|
│ docker-build.yml │
|
||||||
│ ├─ backend-test (parallel) │
|
│ └─ build-and-push │
|
||||||
│ ├─ frontend-build (parallel) │
|
|
||||||
│ └─ build-and-push (after tests) │
|
|
||||||
│ ├─ Build Docker images │
|
│ ├─ Build Docker images │
|
||||||
│ └─ Push to GHCR │
|
│ └─ Push to GHCR │
|
||||||
|
│ (Tag builds also set "latest") │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓ After successful build
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ update-test-badges.yml │
|
||||||
|
│ (workflow_run after docker-build) │
|
||||||
|
│ └─ Run tests, update badge counts │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -179,7 +184,9 @@ gh pr merge --squash --delete-branch
|
|||||||
| File | Trigger | Purpose |
|
| File | Trigger | Purpose |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| `.github/workflows/test.yml` | Pull Requests | Run tests, block PR on failures |
|
| `.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 |
|
| `.github/workflows/docker-build.yml` | Push to main, Tags | Build and push Docker images (+ create GitHub release on tags) |
|
||||||
|
| `.github/workflows/update-test-badges.yml` | After successful docker-build | Update test count badges in README |
|
||||||
|
| `.github/workflows/codeql.yml` | Push to main, PRs, Weekly | Security analysis |
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ jobs:
|
|||||||
# Tests are NOT run here — branch protection on main requires all PR checks
|
# Tests are NOT run here — branch protection on main requires all PR checks
|
||||||
# (backend-test + frontend-build from test.yml) to pass before merge.
|
# (backend-test + frontend-build from test.yml) to pass before merge.
|
||||||
# Tags are created from main, so code is already tested.
|
# Tags are created from main, so code is already tested.
|
||||||
|
#
|
||||||
|
# Tag builds (v*) always set "latest" in addition to the semver tags.
|
||||||
|
# This ensures "latest" always points to the most recent release.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -68,7 +71,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
name: Create Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get version info
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
CURRENT_TAG=${GITHUB_REF#refs/tags/}
|
|
||||||
VERSION=${CURRENT_TAG#v}
|
|
||||||
echo "tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Get previous tag
|
|
||||||
PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${CURRENT_TAG}$" | tail -1)
|
|
||||||
if [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
|
||||||
PREV_TAG=""
|
|
||||||
fi
|
|
||||||
echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Generate release template
|
|
||||||
run: |
|
|
||||||
cat > release_notes.md << 'EOF'
|
|
||||||
## What's New
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Write 1-2 sentences describing the main changes in this release.
|
|
||||||
Example: This release introduces a medication refill tracking feature and improves the mobile user experience.
|
|
||||||
-->
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
<!-- List new features with **bold** names and descriptions -->
|
|
||||||
- **Feature Name**: Description of the feature
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
<!-- List improvements and fixes -->
|
|
||||||
- **Improvement**: Description
|
|
||||||
|
|
||||||
### Where to Find It
|
|
||||||
|
|
||||||
<!-- Tell users where they can access new features -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Images
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/danielvolz/medassist-ng-backend:${{ steps.version.outputs.version }}
|
|
||||||
docker pull ghcr.io/danielvolz/medassist-ng-frontend:${{ steps.version.outputs.version }}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/${{ steps.version.outputs.previous_tag }}...${{ steps.version.outputs.tag }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Create Draft Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
body_path: release_notes.md
|
|
||||||
draft: true
|
|
||||||
generate_release_notes: false
|
|
||||||
name: "Release ${{ steps.version.outputs.tag }}"
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -2,13 +2,10 @@ name: Update Test Badges
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["Build and Push Docker Images"]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'backend/src/**'
|
|
||||||
- 'frontend/src/**'
|
|
||||||
- 'backend/package.json'
|
|
||||||
- 'frontend/package.json'
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -17,6 +14,8 @@ jobs:
|
|||||||
update-badges:
|
update-badges:
|
||||||
name: Update Test Count Badges
|
name: Update Test Count Badges
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Only run after successful docker builds, not failed ones
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Version Bump on Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
version-bump:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
# Extract version from tag (e.g., v1.6.0 -> 1.6.0)
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Extracted version: $VERSION"
|
|
||||||
|
|
||||||
- name: Update package.json versions
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
|
|
||||||
# Update backend/package.json
|
|
||||||
jq --arg v "$VERSION" '.version = $v' backend/package.json > backend/package.json.tmp
|
|
||||||
mv backend/package.json.tmp backend/package.json
|
|
||||||
|
|
||||||
# Update frontend/package.json
|
|
||||||
jq --arg v "$VERSION" '.version = $v' frontend/package.json > frontend/package.json.tmp
|
|
||||||
mv frontend/package.json.tmp frontend/package.json
|
|
||||||
|
|
||||||
echo "Updated versions to $VERSION"
|
|
||||||
cat backend/package.json | head -5
|
|
||||||
cat frontend/package.json | head -5
|
|
||||||
|
|
||||||
- name: Commit and push
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
git add backend/package.json frontend/package.json
|
|
||||||
|
|
||||||
# Only commit if there are changes
|
|
||||||
if git diff --staged --quiet; then
|
|
||||||
echo "No version changes needed"
|
|
||||||
else
|
|
||||||
git commit -m "chore: bump version to ${{ steps.version.outputs.version }} [skip ci]"
|
|
||||||
git push origin main
|
|
||||||
fi
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-494%2F494-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-494%2F494-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-639%2F639-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-645%2F645-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.8.6",
|
"version": "1.8.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.6",
|
"version": "1.8.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -278,7 +278,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
systemLocale,
|
systemLocale,
|
||||||
settingsHook.settings.reminderDaysBefore,
|
settingsHook.settings.reminderDaysBefore,
|
||||||
settingsHook.settings.stockCalculationMode,
|
settingsHook.settings.stockCalculationMode,
|
||||||
doses.takenDoses
|
doses.takenDoses,
|
||||||
|
doses.takenDoseTimestamps
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
medications.meds,
|
medications.meds,
|
||||||
@@ -287,6 +288,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
settingsHook.settings.reminderDaysBefore,
|
settingsHook.settings.reminderDaysBefore,
|
||||||
settingsHook.settings.stockCalculationMode,
|
settingsHook.settings.stockCalculationMode,
|
||||||
doses.takenDoses,
|
doses.takenDoses,
|
||||||
|
doses.takenDoseTimestamps,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// useDoses Hook - Dose tracking state and operations
|
// useDoses Hook - Dose tracking state and operations
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export interface UseDosesReturn {
|
export interface UseDosesReturn {
|
||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
|
takenDoseTimestamps: Map<string, number>;
|
||||||
dismissedDoses: Set<string>;
|
dismissedDoses: Set<string>;
|
||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
@@ -19,25 +20,39 @@ export interface UseDosesReturn {
|
|||||||
|
|
||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
|
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Track in-flight mutations to prevent polling from overwriting optimistic updates
|
||||||
|
const mutationInFlightRef = useRef(0);
|
||||||
|
|
||||||
// Load taken doses from server
|
// Load taken doses from server
|
||||||
const loadTakenDoses = useCallback(async () => {
|
const loadTakenDoses = useCallback(async () => {
|
||||||
|
// Skip polling while mutations are in-flight to prevent race conditions
|
||||||
|
// where a poll response with stale data overwrites optimistic updates
|
||||||
|
if (mutationInFlightRef.current > 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
// Double-check no mutation started while we were fetching
|
||||||
|
if (mutationInFlightRef.current > 0) return;
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
|
const timestamps = new Map<string, number>();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses) {
|
for (const d of data.doses) {
|
||||||
if (d.dismissed) {
|
if (d.dismissed) {
|
||||||
dismissed.add(d.doseId);
|
dismissed.add(d.doseId);
|
||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
|
timestamps.set(d.doseId, d.takenAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
|
setTakenDoseTimestamps(timestamps);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
@@ -77,13 +92,20 @@ export function useDoses(): UseDosesReturn {
|
|||||||
[takenDoses, getDoseId]
|
[takenDoses, getDoseId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const markDoseTaken = useCallback(async (doseId: string) => {
|
const markDoseTaken = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
|
mutationInFlightRef.current++;
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(doseId);
|
next.add(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseTimestamps((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(doseId, Date.now());
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -100,16 +122,34 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseTimestamps((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
|
// Re-sync with server after mutation completes
|
||||||
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[loadTakenDoses]
|
||||||
|
);
|
||||||
|
|
||||||
const undoDoseTaken = useCallback(async (doseId: string) => {
|
const undoDoseTaken = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
|
mutationInFlightRef.current++;
|
||||||
setTakenDoses((prev) => {
|
setTakenDoses((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseTimestamps((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -124,12 +164,19 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.add(doseId);
|
next.add(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
mutationInFlightRef.current--;
|
||||||
|
// Re-sync with server after mutation completes
|
||||||
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[loadTakenDoses]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
takenDoses,
|
takenDoses,
|
||||||
setTakenDoses,
|
setTakenDoses,
|
||||||
|
takenDoseTimestamps,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
|
|||||||
@@ -103,10 +103,14 @@ describe("useDoses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("marks dose as taken optimistically", async () => {
|
it("marks dose as taken optimistically", async () => {
|
||||||
// First call for initial load, subsequent calls for marking dose
|
// First call for initial load, second for marking dose, third for re-sync
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||||
.mockResolvedValueOnce({ ok: true });
|
.mockResolvedValueOnce({ ok: true })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ doses: [{ doseId: "new-dose", takenAt: Date.now(), dismissed: false }] }),
|
||||||
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useDoses());
|
const { result } = renderHook(() => useDoses());
|
||||||
|
|
||||||
@@ -119,7 +123,9 @@ describe("useDoses", () => {
|
|||||||
await result.current.markDoseTaken("new-dose");
|
await result.current.markDoseTaken("new-dose");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.takenDoses.has("new-dose")).toBe(true);
|
expect(result.current.takenDoses.has("new-dose")).toBe(true);
|
||||||
|
});
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/doses/taken",
|
"/api/doses/taken",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -130,10 +136,11 @@ describe("useDoses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reverts optimistic update on error", async () => {
|
it("reverts optimistic update on error", async () => {
|
||||||
// First call for initial load, second for marking dose fails
|
// First call for initial load, second for marking dose fails, third for re-sync
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||||
.mockRejectedValueOnce(new Error("Network error"));
|
.mockRejectedValueOnce(new Error("Network error"))
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
|
|
||||||
const { result } = renderHook(() => useDoses());
|
const { result } = renderHook(() => useDoses());
|
||||||
|
|
||||||
@@ -153,12 +160,13 @@ describe("useDoses", () => {
|
|||||||
|
|
||||||
it("undoes dose taken optimistically", async () => {
|
it("undoes dose taken optimistically", async () => {
|
||||||
const mockDoses = {
|
const mockDoses = {
|
||||||
doses: [{ doseId: "taken-dose", dismissed: false }],
|
doses: [{ doseId: "taken-dose", takenAt: Date.now(), dismissed: false }],
|
||||||
};
|
};
|
||||||
|
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
|
||||||
.mockResolvedValueOnce({ ok: true });
|
.mockResolvedValueOnce({ ok: true })
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||||
|
|
||||||
const { result } = renderHook(() => useDoses());
|
const { result } = renderHook(() => useDoses());
|
||||||
|
|
||||||
@@ -170,7 +178,9 @@ describe("useDoses", () => {
|
|||||||
await result.current.undoDoseTaken("taken-dose");
|
await result.current.undoDoseTaken("taken-dose");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(result.current.takenDoses.has("taken-dose")).toBe(false);
|
expect(result.current.takenDoses.has("taken-dose")).toBe(false);
|
||||||
|
});
|
||||||
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/taken-dose", expect.objectContaining({ method: "DELETE" }));
|
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/taken-dose", expect.objectContaining({ method: "DELETE" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -409,8 +409,9 @@ describe("calculateCoverage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stock correction with dose tracking data also reflects correctly", () => {
|
it("stock correction with dose tracking data also reflects correctly", () => {
|
||||||
// When the user has dose tracking data, the actualConsumed path is used.
|
// In automatic mode, dose tracking data is ignored — stock is always
|
||||||
// Verify that no phantom dose is generated right after a stock correction.
|
// reduced based on the schedule. Verify that tracked doses don't affect
|
||||||
|
// the calculation and that stock correction still resets the baseline.
|
||||||
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||||
const march14 = new Date("2024-03-14T00:00:00").getTime();
|
const march14 = new Date("2024-03-14T00:00:00").getTime();
|
||||||
|
|
||||||
@@ -437,20 +438,23 @@ describe("calculateCoverage", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// User has tracked a dose yesterday (before the correction)
|
// User has tracked a dose yesterday (before the correction)
|
||||||
|
// In automatic mode, this should be ignored — only the schedule matters.
|
||||||
const takenDoses = new Set([`1-0-${march14}`]);
|
const takenDoses = new Set([`1-0-${march14}`]);
|
||||||
|
|
||||||
const result = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
const result = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
||||||
|
|
||||||
expect(result.all).toHaveLength(1);
|
expect(result.all).toHaveLength(1);
|
||||||
// getMedTotal = 30 - 7 = 23.
|
// getMedTotal = 30 - 7 = 23.
|
||||||
// The taken dose from yesterday should NOT be counted (it's before the correction).
|
// Automatic mode ignores tracking data. After correction, consumption
|
||||||
// No new doses should exist since the correction just happened.
|
// restarts from correctionTime + period, which is in the future.
|
||||||
expect(result.all[0].medsLeft).toBe(23);
|
expect(result.all[0].medsLeft).toBe(23);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stock correction consumption resumes after one full period", () => {
|
it("stock correction consumption resumes after one full period", () => {
|
||||||
// After 1 day (for daily medication), the next dose should be consumed.
|
// After correction, the next scheduled dose on the blister's grid should
|
||||||
// Set system time to 1 day + 1 hour after correction.
|
// be counted once its time arrives.
|
||||||
|
// Correction at March 14 12:00, blister start 08:00 daily →
|
||||||
|
// next dose after correction = March 15 08:00. Now is 13:00 on March 15 → 1 dose.
|
||||||
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
||||||
vi.setSystemTime(new Date("2024-03-15T13:00:00Z")); // 25 hours after correction
|
vi.setSystemTime(new Date("2024-03-15T13:00:00Z")); // 25 hours after correction
|
||||||
|
|
||||||
@@ -484,14 +488,205 @@ describe("calculateCoverage", () => {
|
|||||||
expect(result.all[0].medsLeft).toBe(22);
|
expect(result.all[0].medsLeft).toBe(22);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("manual mode: stock correction excludes same-day taken doses", () => {
|
it("stock correction aligns to schedule grid, not correction timestamp", () => {
|
||||||
// BUG FIX: In manual mode, doses taken on the same day as a stock correction
|
// BUG: When correction happened just before a scheduled dose (e.g. 15:40
|
||||||
// were counted as consumed (>= comparison with date-only timestamps).
|
// correction, 15:42 dose), the old code added 1 full period to the correction
|
||||||
// The user already accounted for today's consumption when setting the stock count.
|
// time (15:40 + 24h = tomorrow 15:40), missing today's 15:42 dose entirely.
|
||||||
//
|
// FIX: Align effectiveStart to the blister's schedule grid so that the first
|
||||||
// Scenario: User has 110 pills, took 1 dose today, corrects to 111.
|
// dose counted is the next one on the schedule after the correction.
|
||||||
// Bug: medsLeft = 111 - 1 = 110 (today's dose counted)
|
const correctionTime = new Date("2024-03-14T15:40:00Z"); // 2 min before dose
|
||||||
// Fix: medsLeft = 111 - 0 = 111 (today's dose excluded)
|
vi.setSystemTime(new Date("2024-03-14T15:45:00Z")); // 5 min after correction, 3 min after dose
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "TestMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -5, // 30 - 5 = 25 pills
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-01T15:42:00Z", // Daily at 15:42
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// Correction at 15:40, dose at 15:42, now at 15:45.
|
||||||
|
// The 15:42 dose is AFTER the correction → should be counted.
|
||||||
|
// medsLeft = 25 - 1 = 24
|
||||||
|
expect(result.all[0].medsLeft).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stock correction shortly after a dose does not count that dose again", () => {
|
||||||
|
// If correction happens shortly AFTER a dose, that dose is already reflected
|
||||||
|
// in the stock count and should NOT be counted again.
|
||||||
|
const correctionTime = new Date("2024-03-14T15:45:00Z"); // 3 min AFTER the 15:42 dose
|
||||||
|
vi.setSystemTime(new Date("2024-03-14T16:00:00Z")); // 15 min after correction
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "TestMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -5,
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-01T15:42:00Z", // Daily at 15:42
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// Correction at 15:45, after today's 15:42 dose → next dose is TOMORROW 15:42.
|
||||||
|
// Now is 16:00 today → next dose hasn't arrived yet → 0 consumed.
|
||||||
|
// medsLeft = 25
|
||||||
|
expect(result.all[0].medsLeft).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatic mode ignores past dose tracking data", () => {
|
||||||
|
// Automatic mode uses time-based expected consumption for past doses.
|
||||||
|
// Even if a user marks only some past doses as taken, the stock should still
|
||||||
|
// decrease for ALL scheduled doses whose time has passed.
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "TestMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-10T09:00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// System time is March 15 12:00, start is 09:00 → 6 occurrences (March 10-15)
|
||||||
|
const march10 = new Date("2024-03-10T00:00:00").getTime();
|
||||||
|
const march11 = new Date("2024-03-11T00:00:00").getTime();
|
||||||
|
|
||||||
|
// User only marked 2 out of 6 past doses as taken
|
||||||
|
const takenDoses = new Set([`1-0-${march10}`, `1-0-${march11}`]);
|
||||||
|
|
||||||
|
const resultWithTracking = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
||||||
|
const resultWithoutTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||||
|
|
||||||
|
// Both should have the same medsLeft — past tracking data doesn't reduce extra
|
||||||
|
expect(resultWithTracking.all[0].medsLeft).toBe(resultWithoutTracking.all[0].medsLeft);
|
||||||
|
// 30 pills - 6 consumed = 24
|
||||||
|
expect(resultWithTracking.all[0].medsLeft).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatic mode counts early-taken future doses", () => {
|
||||||
|
// If a user marks a dose as taken BEFORE the scheduled time,
|
||||||
|
// it should count as consumed immediately (early intake).
|
||||||
|
// System time is March 15 12:00, intake at 21:00 → today's dose not yet auto-consumed
|
||||||
|
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "TestMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-10T21:00:00", // 21:00 = after current time 12:00
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 5 occurrences auto-consumed: March 10-14 (all at 21:00, which is past)
|
||||||
|
// March 15 at 21:00 hasn't passed yet (it's only 12:00)
|
||||||
|
const resultNoTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||||
|
expect(resultNoTracking.all[0].medsLeft).toBe(25); // 30 - 5 = 25
|
||||||
|
|
||||||
|
// User marks today's (March 15) dose as taken early at 12:00
|
||||||
|
const march15 = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
const takenDoses = new Set([`1-0-${march15}`]);
|
||||||
|
|
||||||
|
const resultEarlyTaken = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
||||||
|
// 5 auto + 1 early = 6 consumed → 30 - 6 = 24
|
||||||
|
expect(resultEarlyTaken.all[0].medsLeft).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatic mode does not double-count after intake time passes", () => {
|
||||||
|
// After the scheduled time, the dose is auto-consumed.
|
||||||
|
// If it was also marked as taken (earlier), it should NOT be counted twice.
|
||||||
|
vi.setSystemTime(new Date("2024-03-15T22:00:00Z")); // After 21:00
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "TestMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-10T21:00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 6 occurrences auto-consumed: March 10-15 (all at 21:00, now it's 22:00)
|
||||||
|
const march15 = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
const march14 = new Date("2024-03-14T00:00:00").getTime();
|
||||||
|
// User marked March 14 and 15 as taken (both already auto-consumed by now)
|
||||||
|
const takenDoses = new Set([`1-0-${march14}`, `1-0-${march15}`]);
|
||||||
|
|
||||||
|
const resultTracked = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
|
||||||
|
const resultNoTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
|
||||||
|
|
||||||
|
// Both should be 24 (30 - 6). No double counting!
|
||||||
|
expect(resultTracked.all[0].medsLeft).toBe(24);
|
||||||
|
expect(resultNoTracking.all[0].medsLeft).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual mode: dose taken BEFORE stock correction is excluded", () => {
|
||||||
|
// When a user corrects stock, any dose marked BEFORE the correction
|
||||||
|
// is already reflected in the corrected count and should NOT be counted.
|
||||||
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||||
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
|
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
@@ -517,17 +712,59 @@ describe("calculateCoverage", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// User took a dose today (before the correction)
|
// User took a dose today at 10am (BEFORE the correction at 12pm)
|
||||||
const takenDoses = new Set([`1-0-${todayMidnight}`]);
|
const doseId = `1-0-${todayMidnight}`;
|
||||||
|
const takenDoses = new Set([doseId]);
|
||||||
|
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T10:00:00Z").getTime()]]);
|
||||||
|
|
||||||
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
expect(result.all).toHaveLength(1);
|
expect(result.all).toHaveLength(1);
|
||||||
// getMedTotal = 196 - 85 = 111
|
// getMedTotal = 196 - 85 = 111
|
||||||
// Today's taken dose should NOT be counted (same day as correction)
|
// Dose was taken BEFORE correction → NOT counted
|
||||||
expect(result.all[0].medsLeft).toBe(111);
|
expect(result.all[0].medsLeft).toBe(111);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("manual mode: dose taken AFTER stock correction is counted", () => {
|
||||||
|
// When a user corrects stock and then takes a dose, that dose SHOULD be counted.
|
||||||
|
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||||
|
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "DailyMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 14,
|
||||||
|
pillsPerBlister: 14,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -85, // 196 - 85 = 111 pills
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-01-01T08:00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// User took a dose today at 2pm (AFTER the correction at 12pm)
|
||||||
|
const doseId = `1-0-${todayMidnight}`;
|
||||||
|
const takenDoses = new Set([doseId]);
|
||||||
|
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T14:00:00Z").getTime()]]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// getMedTotal = 196 - 85 = 111
|
||||||
|
// Dose was taken AFTER correction → counted → 111 - 1 = 110
|
||||||
|
expect(result.all[0].medsLeft).toBe(110);
|
||||||
|
});
|
||||||
|
|
||||||
it("manual mode: stock correction counts next-day taken doses", () => {
|
it("manual mode: stock correction counts next-day taken doses", () => {
|
||||||
// After a stock correction, doses taken the next day SHOULD be counted.
|
// After a stock correction, doses taken the next day SHOULD be counted.
|
||||||
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
||||||
@@ -555,14 +792,16 @@ describe("calculateCoverage", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// User takes dose on March 15 (day after correction on March 14)
|
// User takes dose on March 15 at 8am (day after correction on March 14)
|
||||||
const takenDoses = new Set([`1-0-${march15Midnight}`]);
|
const doseId = `1-0-${march15Midnight}`;
|
||||||
|
const takenDoses = new Set([doseId]);
|
||||||
|
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T08:00:00Z").getTime()]]);
|
||||||
|
|
||||||
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
expect(result.all).toHaveLength(1);
|
expect(result.all).toHaveLength(1);
|
||||||
// getMedTotal = 30 - 7 = 23
|
// getMedTotal = 30 - 7 = 23
|
||||||
// March 15 dose should be counted (day after correction)
|
// March 15 dose should be counted (taken after correction)
|
||||||
expect(result.all[0].medsLeft).toBe(22);
|
expect(result.all[0].medsLeft).toBe(22);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -592,9 +831,16 @@ describe("calculateCoverage", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// User took doses on March 14 and 15
|
// User took doses on March 14 and 15
|
||||||
const takenDoses = new Set([`1-0-${march14Midnight}`, `1-0-${march15Midnight}`]);
|
const doseId1 = `1-0-${march14Midnight}`;
|
||||||
|
const doseId2 = `1-0-${march15Midnight}`;
|
||||||
|
const takenDoses = new Set([doseId1, doseId2]);
|
||||||
|
// No stock correction → takenAt doesn't matter, but provide for completeness
|
||||||
|
const takenDoseTimestamps = new Map([
|
||||||
|
[doseId1, new Date("2024-03-14T08:00:00Z").getTime()],
|
||||||
|
[doseId2, new Date("2024-03-15T08:00:00Z").getTime()],
|
||||||
|
]);
|
||||||
|
|
||||||
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
expect(result.all).toHaveLength(1);
|
expect(result.all).toHaveLength(1);
|
||||||
// Both doses should be counted: medsLeft = 30 - 2 = 28
|
// Both doses should be counted: medsLeft = 30 - 2 = 28
|
||||||
@@ -603,7 +849,7 @@ describe("calculateCoverage", () => {
|
|||||||
|
|
||||||
it("manual mode: stock correction with multiple medications", () => {
|
it("manual mode: stock correction with multiple medications", () => {
|
||||||
// Regression test: 3 medications (daily, daily, weekly).
|
// Regression test: 3 medications (daily, daily, weekly).
|
||||||
// Stock correction on all 3. The daily ones have same-day taken doses.
|
// Stock correction on all 3. Daily meds have doses taken BEFORE correction.
|
||||||
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
const correctionTime = new Date("2024-03-15T12:00:00Z");
|
||||||
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
|
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
@@ -649,21 +895,113 @@ describe("calculateCoverage", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Daily meds have same-day taken doses, weekly med does not
|
// Daily meds have same-day doses taken BEFORE correction (at 8am, correction at 12pm)
|
||||||
const takenDoses = new Set([`1-0-${todayMidnight}`, `2-0-${todayMidnight}`]);
|
const doseId1 = `1-0-${todayMidnight}`;
|
||||||
|
const doseId2 = `2-0-${todayMidnight}`;
|
||||||
|
const takenDoses = new Set([doseId1, doseId2]);
|
||||||
|
const takenDoseTimestamps = new Map([
|
||||||
|
[doseId1, new Date("2024-03-15T08:00:00Z").getTime()], // Before correction
|
||||||
|
[doseId2, new Date("2024-03-15T09:00:00Z").getTime()], // Before correction
|
||||||
|
]);
|
||||||
|
|
||||||
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
expect(result.all).toHaveLength(3);
|
expect(result.all).toHaveLength(3);
|
||||||
const daily1 = result.all.find((c) => c.name === "DailyMed1")!;
|
const daily1 = result.all.find((c) => c.name === "DailyMed1")!;
|
||||||
const daily2 = result.all.find((c) => c.name === "DailyMed2")!;
|
const daily2 = result.all.find((c) => c.name === "DailyMed2")!;
|
||||||
const weekly = result.all.find((c) => c.name === "WeeklyMed")!;
|
const weekly = result.all.find((c) => c.name === "WeeklyMed")!;
|
||||||
|
|
||||||
// All three should reflect full stock (same-day doses excluded)
|
// All three should reflect full stock (doses taken before correction → excluded)
|
||||||
expect(daily1.medsLeft).toBe(111);
|
expect(daily1.medsLeft).toBe(111);
|
||||||
expect(daily2.medsLeft).toBe(20);
|
expect(daily2.medsLeft).toBe(20);
|
||||||
expect(weekly.medsLeft).toBe(8);
|
expect(weekly.medsLeft).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("manual mode: person-suffix dose IDs are counted correctly", () => {
|
||||||
|
// BUG HUNT: In prod (manual mode), dose IDs have a person suffix like
|
||||||
|
// "31-0-1770505200000-Daniel". Does the manual mode code correctly parse
|
||||||
|
// and count these?
|
||||||
|
const march14 = new Date("2024-03-14T00:00:00").getTime();
|
||||||
|
const march15 = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
name: "ProdMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-01T08:00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dose IDs with person suffix (as prod generates them)
|
||||||
|
const doseId1 = `31-0-${march14}-Daniel`;
|
||||||
|
const doseId2 = `31-0-${march15}-Daniel`;
|
||||||
|
const takenDoses = new Set([doseId1, doseId2]);
|
||||||
|
// No stock correction → all counted
|
||||||
|
const takenDoseTimestamps = new Map([
|
||||||
|
[doseId1, new Date("2024-03-14T08:00:00Z").getTime()],
|
||||||
|
[doseId2, new Date("2024-03-15T08:00:00Z").getTime()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// Both doses should be counted: medsLeft = 30 - 2 = 28
|
||||||
|
expect(result.all[0].medsLeft).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual mode: future dose taken today counts immediately", () => {
|
||||||
|
// User marks a future dose (later today) as taken.
|
||||||
|
// It should be counted in manual mode immediately.
|
||||||
|
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
|
||||||
|
const march15 = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
name: "ProdMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: ["Daniel"],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-01T21:00:00", // 21:00, still in future at 12:00
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// No doses taken → 30 pills
|
||||||
|
const resultBefore = calculateCoverage(meds, [], "en", 7, "manual", new Set());
|
||||||
|
expect(resultBefore.all[0].medsLeft).toBe(30);
|
||||||
|
|
||||||
|
// Take today's dose (future time) → 29 pills
|
||||||
|
const doseId = `31-0-${march15}-Daniel`;
|
||||||
|
const takenDoses = new Set([doseId]);
|
||||||
|
const takenDoseTimestamps = new Map([[doseId, Date.now()]]);
|
||||||
|
const resultAfter = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
|
||||||
|
expect(resultAfter.all[0].medsLeft).toBe(29);
|
||||||
|
|
||||||
|
// Undo → back to 30 pills
|
||||||
|
const resultUndo = calculateCoverage(meds, [], "en", 7, "manual", new Set());
|
||||||
|
expect(resultUndo.all[0].medsLeft).toBe(30);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getStockStatus", () => {
|
describe("getStockStatus", () => {
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export function calculateCoverage(
|
|||||||
locale: string,
|
locale: string,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
stockCalculationMode: "automatic" | "manual",
|
stockCalculationMode: "automatic" | "manual",
|
||||||
takenDoses: Set<string>
|
takenDoses: Set<string>,
|
||||||
|
takenDoseTimestamps?: Map<string, number>
|
||||||
): { low: Coverage[]; all: Coverage[] } {
|
): { low: Coverage[]; all: Coverage[] } {
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -122,60 +123,90 @@ export function calculateCoverage(
|
|||||||
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
|
||||||
if (stockCalculationMode === "automatic") {
|
if (stockCalculationMode === "automatic") {
|
||||||
// In automatic mode, calculate expected consumption based on time
|
// In automatic mode, stock is reduced automatically based on the schedule.
|
||||||
// but also account for manual corrections (doses marked as not taken)
|
// Every scheduled dose counts as consumed once its time has passed.
|
||||||
|
// Additionally, if a user marks a future dose as taken BEFORE the scheduled
|
||||||
|
// time (early intake), that dose is also counted as consumed immediately.
|
||||||
|
// This prevents double-counting: once the scheduled time arrives, the dose
|
||||||
|
// was already counted via the early-taken path, not again via time.
|
||||||
blisters.forEach((s, blisterIdx) => {
|
blisters.forEach((s, blisterIdx) => {
|
||||||
const blisterStart = new Date(s.start).getTime();
|
const blisterStart = new Date(s.start).getTime();
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||||
|
|
||||||
// After a stock correction, start counting consumption from the NEXT
|
// After a stock correction, start counting consumption from the NEXT
|
||||||
// scheduled dose, because the user's pill count already reflects all
|
// scheduled dose on this blister's grid, because the user's pill count
|
||||||
// consumption up to the correction time.
|
// already reflects all consumption up to the correction time.
|
||||||
|
// We align to the schedule grid so that e.g. correction at 15:40 with
|
||||||
|
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
|
||||||
|
// tomorrow's dose (24h later as the old code did).
|
||||||
let effectiveStart: number;
|
let effectiveStart: number;
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
effectiveStart = stockCorrectionCutoff + period;
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
} else {
|
} else {
|
||||||
effectiveStart = blisterStart;
|
effectiveStart = blisterStart;
|
||||||
}
|
}
|
||||||
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
if (Number.isNaN(effectiveStart)) return;
|
||||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
|
||||||
const intake = intakes[blisterIdx];
|
const intake = intakes[blisterIdx];
|
||||||
const intakePerson = intake?.takenBy;
|
const intakePerson = intake?.takenBy;
|
||||||
|
|
||||||
// For per-intake takenBy, only count for that person
|
// For per-intake takenBy, only count for that person
|
||||||
// For legacy (no takenBy), count for all people in medication takenBy
|
// For legacy (no takenBy), count for all people in medication takenBy
|
||||||
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
|
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
|
||||||
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
|
||||||
|
|
||||||
// Count how many doses were actually marked as taken for this blister
|
// Time-based: count doses where the scheduled time has already passed
|
||||||
let actualConsumed = 0;
|
let timeBasedConsumed = 0;
|
||||||
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|
||||||
// Generate all expected dose IDs for this blister up to now
|
if (effectiveStart <= now) {
|
||||||
for (let i = 0; i < occurrences; i++) {
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
const doseDate = new Date(effectiveStart + i * period);
|
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
|
||||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
|
||||||
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
|
|
||||||
|
|
||||||
// Check if each person has taken this dose
|
// Date-only timestamp of the last auto-consumed dose
|
||||||
for (const person of peopleForThisIntake) {
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
lastAutoConsumedDateMs = new Date(
|
||||||
if (takenDoses.has(doseId)) {
|
lastDoseTime.getFullYear(),
|
||||||
actualConsumed += s.usage;
|
lastDoseTime.getMonth(),
|
||||||
|
lastDoseTime.getDate()
|
||||||
|
).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early intakes: count future doses already marked as taken.
|
||||||
|
// The cutoff is the later of: last auto-consumed date or stock correction date.
|
||||||
|
// This prevents double-counting (time-based + early-taken) and respects corrections.
|
||||||
|
const stockCorrectionDateOnly =
|
||||||
|
stockCorrectionCutoff > 0
|
||||||
|
? new Date(
|
||||||
|
new Date(stockCorrectionCutoff).getFullYear(),
|
||||||
|
new Date(stockCorrectionCutoff).getMonth(),
|
||||||
|
new Date(stockCorrectionCutoff).getDate()
|
||||||
|
).getTime()
|
||||||
|
: 0;
|
||||||
|
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoses) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const medId = parseInt(parts[0], 10);
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += s.usage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have tracking data (any doses marked), use actual consumed
|
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||||
// Otherwise fall back to expected (for backwards compatibility)
|
|
||||||
const hasTrackingData = Array.from(takenDoses).some((id) => {
|
|
||||||
const parts = id.split("-");
|
|
||||||
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
|
|
||||||
});
|
|
||||||
|
|
||||||
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// In manual mode, only count doses that are explicitly marked as taken
|
// In manual mode, only count doses that are explicitly marked as taken.
|
||||||
|
// For stock correction filtering, we use the actual time the dose was marked
|
||||||
|
// as taken (takenAt), not the scheduled date. This correctly handles same-day
|
||||||
|
// scenarios: if a user corrects stock at 3pm, then takes a dose at 4pm,
|
||||||
|
// the dose counts because takenAt (4pm) > correctionTime (3pm).
|
||||||
takenDoses.forEach((doseId) => {
|
takenDoses.forEach((doseId) => {
|
||||||
const parts = doseId.split("-");
|
const parts = doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
@@ -190,19 +221,17 @@ export function calculateCoverage(
|
|||||||
blisterStartDate.getMonth(),
|
blisterStartDate.getMonth(),
|
||||||
blisterStartDate.getDate()
|
blisterStartDate.getDate()
|
||||||
).getTime();
|
).getTime();
|
||||||
// Convert stock correction cutoff to date-only as well
|
|
||||||
const stockCorrectionDateOnly =
|
// Use actual takenAt timestamp for stock correction comparison.
|
||||||
stockCorrectionCutoff > 0
|
// A dose counts only if it was MARKED after the stock correction,
|
||||||
? new Date(
|
// regardless of what day it was scheduled for.
|
||||||
new Date(stockCorrectionCutoff).getFullYear(),
|
const takenAt = takenDoseTimestamps?.get(doseId) ?? 0;
|
||||||
new Date(stockCorrectionCutoff).getMonth(),
|
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
new Date(stockCorrectionCutoff).getDate()
|
|
||||||
).getTime()
|
|
||||||
: 0;
|
|
||||||
if (
|
if (
|
||||||
!Number.isNaN(blisterStartDateOnly) &&
|
!Number.isNaN(blisterStartDateOnly) &&
|
||||||
doseTimestamp >= blisterStartDateOnly &&
|
doseTimestamp >= blisterStartDateOnly &&
|
||||||
doseTimestamp > stockCorrectionDateOnly
|
afterCorrectionOrNoCorrectionMs
|
||||||
) {
|
) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += blisters[blisterIdx].usage;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user