Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b91717fc19 | |||
| a065adcd82 | |||
| 6edf2fa341 | |||
| 9e3d548536 |
@@ -129,13 +129,26 @@ Apply these rules strictly:
|
|||||||
|
|
||||||
## Task 3: Execute Release
|
## Task 3: Execute Release
|
||||||
|
|
||||||
Use the release script whenever possible:
|
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/release.sh <patch|minor|major>
|
./scripts/release.sh <patch|minor|major|x.y.z>
|
||||||
```
|
```
|
||||||
|
|
||||||
This script handles: branch creation → version bump → PR → CI wait → merge → signed tag → push.
|
The script performs these steps in order:
|
||||||
|
1. Checks out and updates `main`
|
||||||
|
2. Creates release branch `chore/release-X.Y.Z`
|
||||||
|
3. Bumps version in `backend/package.json` and `frontend/package.json`
|
||||||
|
4. Commits, pushes, and creates a PR
|
||||||
|
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
|
||||||
|
6. Merges the PR (squash + delete branch)
|
||||||
|
7. Creates a signed tag `vX.Y.Z` and pushes it
|
||||||
|
|
||||||
|
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
|
||||||
|
|
||||||
|
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
|
||||||
|
|
||||||
|
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
|
||||||
|
|
||||||
### Version Files (MANDATORY)
|
### Version Files (MANDATORY)
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
|
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
|
||||||
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
|
||||||
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository.
|
- **NEVER create PRs, push, or merge**: Only the **release-manager agent** (`@release-manager`) is allowed to create Pull Requests, push branches to the remote, or merge code. Regular agents and Copilot MUST NOT perform any git operations that affect the remote repository (no `git push`, no `gh pr create`, no `gh pr merge`). Present your local changes and tell the user to invoke `@release-manager` when ready to ship.
|
||||||
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
|
||||||
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
|
||||||
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
|
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
|
||||||
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
|
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
|
||||||
- **Fix bugs, don't test around them**: If you discover incorrect behavior in the code while writing tests, ALWAYS fix the buggy code first, then write tests that verify the correct behavior. NEVER write tests that mimic or assert broken behavior. The user's time is finite and irreplaceable — every bug left unfixed wastes it.
|
- **Fix bugs, don't test around them**: If you discover incorrect behavior in the code while writing tests, ALWAYS fix the buggy code first, then write tests that verify the correct behavior. NEVER write tests that mimic or assert broken behavior. The user's time is finite and irreplaceable — every bug left unfixed wastes it.
|
||||||
|
- **Keep README.md up to date**: After implementing code changes, check whether the `README.md` needs to be updated (e.g., new features, changed ENV variables, new commands, changed architecture, new endpoints, updated screenshots). If changes are relevant to the README, **ask the user for confirmation** before updating it. Do NOT silently update the README — always present the proposed README changes and wait for approval. Examples of README-relevant changes: new ENV variables, new API endpoints, new UI features, changed setup/install steps, new dependencies, changed Docker configuration.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
|||||||
@@ -25,50 +25,12 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Run Tests First
|
# Build and Push Docker Images
|
||||||
# =============================================================================
|
# Tests are NOT run here — branch protection on main requires all PR checks
|
||||||
backend-test:
|
# (backend-test + frontend-build from test.yml) to pass before merge.
|
||||||
name: Backend Tests
|
# Tags are created from main, so code is already tested.
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: backend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: backend/package-lock.json
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx tsc --noEmit
|
|
||||||
- run: npm run test:run
|
|
||||||
|
|
||||||
frontend-build:
|
|
||||||
name: Frontend Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run build
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Build and Push Docker Images (only after tests pass)
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: [backend-test, frontend-build]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ services:
|
|||||||
- /tmp:noexec,nosuid,size=64m
|
- /tmp:noexec,nosuid,size=64m
|
||||||
- /var/cache/nginx:noexec,nosuid,size=64m
|
- /var/cache/nginx:noexec,nosuid,size=64m
|
||||||
- /var/run:noexec,nosuid,size=64m
|
- /var/run:noexec,nosuid,size=64m
|
||||||
- /etc/nginx/conf.d:noexec,nosuid,size=1m
|
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||||
import { getMedTotal } from "../types";
|
import { getMedTotal, getPackageSize } from "../types";
|
||||||
|
|
||||||
export interface UseRefillReturn {
|
export interface UseRefillReturn {
|
||||||
// Refill state
|
// Refill state
|
||||||
@@ -146,8 +146,8 @@ export function useRefill(): UseRefillReturn {
|
|||||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||||
|
|
||||||
// The "base" from DB structure (without any stockAdjustment)
|
// The "base" from DB structure (without any stockAdjustment)
|
||||||
const baseTotal =
|
// Use getPackageSize() which handles both blister and bottle types correctly
|
||||||
selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
const baseTotal = getPackageSize(selectedMed);
|
||||||
|
|
||||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||||
const newStockAdjustment = desiredTotal - baseTotal;
|
const newStockAdjustment = desiredTotal - baseTotal;
|
||||||
|
|||||||
@@ -287,6 +287,114 @@ describe("useRefill", () => {
|
|||||||
expect(mockLoadMeds).toHaveBeenCalled();
|
expect(mockLoadMeds).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stock correction uses correct base for bottle type medications", async () => {
|
||||||
|
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
|
||||||
|
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
|
||||||
|
// This mismatch caused the correction to compute the wrong stockAdjustment.
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const bottleMed: Medication = {
|
||||||
|
id: 4,
|
||||||
|
name: "Pills in a Box",
|
||||||
|
packageType: "bottle",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 1,
|
||||||
|
looseTablets: 150,
|
||||||
|
stockAdjustment: -2,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
|
||||||
|
// getPackageSize for bottle = looseTablets = 150
|
||||||
|
|
||||||
|
const mockLoadMeds = vi.fn();
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
// Pre-fill: user sees 148 pills (148 / 1 = 148 full, 0 partial)
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(bottleMed, {
|
||||||
|
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User adds +1 → 149 full blisters (pillsPerBlister=1)
|
||||||
|
act(() => {
|
||||||
|
result.current.setEditStockFullBlisters(149);
|
||||||
|
result.current.setEditStockPartialBlisterPills(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(4, bottleMed, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// desiredTotal = 149 * 1 + 0 = 149
|
||||||
|
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
|
||||||
|
// newStockAdjustment = 149 - 150 = -1
|
||||||
|
// → getMedTotal = 150 + (-1) = 149 ✓
|
||||||
|
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||||
|
(call: [string, RequestInit]) => call[0] === "/api/medications/4/stock-adjustment"
|
||||||
|
);
|
||||||
|
expect(fetchCall).toBeDefined();
|
||||||
|
const body = JSON.parse(fetchCall![1].body as string);
|
||||||
|
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stock correction uses correct base for blister type medications", async () => {
|
||||||
|
// Ensure blister type still works correctly after the bottle fix
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const blisterMed: Medication = {
|
||||||
|
id: 2,
|
||||||
|
name: "Blister Med",
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 5,
|
||||||
|
pillsPerBlister: 5,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: 1,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// getMedTotal for blister = 1*5*5 + 0 + 1 = 26
|
||||||
|
// getPackageSize for blister = 1*5*5 + 0 = 25
|
||||||
|
|
||||||
|
const mockLoadMeds = vi.fn();
|
||||||
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
// User sees 26 pills → 5 full blisters (5pills each) + 1 partial
|
||||||
|
act(() => {
|
||||||
|
result.current.openEditStockModal(blisterMed, {
|
||||||
|
all: [{ name: "Blister Med", medsLeft: 26, daysLeft: 26 }] as Coverage[],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// User changes to 27 (+1): 5 full + 2 partial
|
||||||
|
act(() => {
|
||||||
|
result.current.setEditStockFullBlisters(5);
|
||||||
|
result.current.setEditStockPartialBlisterPills(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitStockCorrection(2, blisterMed, mockLoadMeds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// desiredTotal = 5 * 5 + 2 = 27
|
||||||
|
// baseTotal = getPackageSize(blister) = 1*5*5 + 0 = 25
|
||||||
|
// newStockAdjustment = 27 - 25 = 2
|
||||||
|
// → getMedTotal = 25 + 2 = 27 ✓
|
||||||
|
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||||
|
(call: [string, RequestInit]) => call[0] === "/api/medications/2/stock-adjustment"
|
||||||
|
);
|
||||||
|
expect(fetchCall).toBeDefined();
|
||||||
|
const body = JSON.parse(fetchCall![1].body as string);
|
||||||
|
expect(body.stockAdjustment).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows setting state directly", () => {
|
it("allows setting state directly", () => {
|
||||||
const { result } = renderHook(() => useRefill());
|
const { result } = renderHook(() => useRefill());
|
||||||
|
|
||||||
|
|||||||
@@ -483,6 +483,187 @@ describe("calculateCoverage", () => {
|
|||||||
// medsLeft = 23 - 1 = 22
|
// medsLeft = 23 - 1 = 22
|
||||||
expect(result.all[0].medsLeft).toBe(22);
|
expect(result.all[0].medsLeft).toBe(22);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("manual mode: stock correction excludes same-day taken doses", () => {
|
||||||
|
// BUG FIX: In manual mode, doses taken on the same day as a stock correction
|
||||||
|
// were counted as consumed (>= comparison with date-only timestamps).
|
||||||
|
// The user already accounted for today's consumption when setting the stock count.
|
||||||
|
//
|
||||||
|
// Scenario: User has 110 pills, took 1 dose today, corrects to 111.
|
||||||
|
// Bug: medsLeft = 111 - 1 = 110 (today's dose counted)
|
||||||
|
// Fix: medsLeft = 111 - 0 = 111 (today's dose excluded)
|
||||||
|
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 (before the correction)
|
||||||
|
const takenDoses = new Set([`1-0-${todayMidnight}`]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// getMedTotal = 196 - 85 = 111
|
||||||
|
// Today's taken dose should NOT be counted (same day as correction)
|
||||||
|
expect(result.all[0].medsLeft).toBe(111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual mode: stock correction counts next-day taken doses", () => {
|
||||||
|
// After a stock correction, doses taken the next day SHOULD be counted.
|
||||||
|
const correctionTime = new Date("2024-03-14T12:00:00Z");
|
||||||
|
const march15Midnight = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "DailyMed",
|
||||||
|
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 takes dose on March 15 (day after correction on March 14)
|
||||||
|
const takenDoses = new Set([`1-0-${march15Midnight}`]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// getMedTotal = 30 - 7 = 23
|
||||||
|
// March 15 dose should be counted (day after correction)
|
||||||
|
expect(result.all[0].medsLeft).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual mode: no stock correction counts all taken doses", () => {
|
||||||
|
// Without any stock correction, all taken doses should be counted
|
||||||
|
const march14Midnight = new Date("2024-03-14T00:00:00").getTime();
|
||||||
|
const march15Midnight = new Date("2024-03-15T00:00:00").getTime();
|
||||||
|
|
||||||
|
const meds: Medication[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "DailyMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: "2024-03-01T08:00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// User took doses on March 14 and 15
|
||||||
|
const takenDoses = new Set([`1-0-${march14Midnight}`, `1-0-${march15Midnight}`]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(1);
|
||||||
|
// Both doses should be counted: medsLeft = 30 - 2 = 28
|
||||||
|
expect(result.all[0].medsLeft).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manual mode: stock correction with multiple medications", () => {
|
||||||
|
// Regression test: 3 medications (daily, daily, weekly).
|
||||||
|
// Stock correction on all 3. The daily ones have same-day taken doses.
|
||||||
|
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: "DailyMed1",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 14,
|
||||||
|
pillsPerBlister: 14,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -85, // 196 - 85 = 111
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T08:00:00" }],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "DailyMed2",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 30,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -10, // 30 - 10 = 20
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "WeeklyMed",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
looseTablets: 0,
|
||||||
|
stockAdjustment: -2, // 10 - 2 = 8
|
||||||
|
lastStockCorrectionAt: correctionTime.toISOString(),
|
||||||
|
takenBy: [],
|
||||||
|
blisters: [{ usage: 1, every: 7, start: "2024-01-05T10:00:00" }],
|
||||||
|
updatedAt: correctionTime.toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Daily meds have same-day taken doses, weekly med does not
|
||||||
|
const takenDoses = new Set([`1-0-${todayMidnight}`, `2-0-${todayMidnight}`]);
|
||||||
|
|
||||||
|
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
|
||||||
|
|
||||||
|
expect(result.all).toHaveLength(3);
|
||||||
|
const daily1 = result.all.find((c) => c.name === "DailyMed1")!;
|
||||||
|
const daily2 = result.all.find((c) => c.name === "DailyMed2")!;
|
||||||
|
const weekly = result.all.find((c) => c.name === "WeeklyMed")!;
|
||||||
|
|
||||||
|
// All three should reflect full stock (same-day doses excluded)
|
||||||
|
expect(daily1.medsLeft).toBe(111);
|
||||||
|
expect(daily2.medsLeft).toBe(20);
|
||||||
|
expect(weekly.medsLeft).toBe(8);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getStockStatus", () => {
|
describe("getStockStatus", () => {
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function calculateCoverage(
|
|||||||
if (
|
if (
|
||||||
!Number.isNaN(blisterStartDateOnly) &&
|
!Number.isNaN(blisterStartDateOnly) &&
|
||||||
doseTimestamp >= blisterStartDateOnly &&
|
doseTimestamp >= blisterStartDateOnly &&
|
||||||
doseTimestamp >= stockCorrectionDateOnly
|
doseTimestamp > stockCorrectionDateOnly
|
||||||
) {
|
) {
|
||||||
consumed += blisters[blisterIdx].usage;
|
consumed += blisters[blisterIdx].usage;
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-54
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MedAssist Release Script
|
# MedAssist Release Script (non-interactive)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/release.sh patch # 1.0.0 -> 1.0.1 (bugfixes)
|
# ./scripts/release.sh patch # 1.0.0 -> 1.0.1 (bugfixes)
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
|
||||||
# ./scripts/release.sh 1.2.3 # explicit version
|
# ./scripts/release.sh 1.2.3 # explicit version
|
||||||
#
|
#
|
||||||
# This script creates a PR for the version bump (required due to branch protection),
|
# Fully non-interactive: no y/N prompts. Designed to be called by AI agents
|
||||||
# waits for CI, merges it, and then creates a signed tag for the release.
|
# or CI systems. Creates a PR for the version bump (required due to branch
|
||||||
|
# protection), waits for CI with retry logic, merges, and creates a signed tag.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -21,45 +22,98 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# GitHub repo
|
# Configuration
|
||||||
GITHUB_REPO="DanielVolz/medassist-ng"
|
GITHUB_REPO="DanielVolz/medassist-ng"
|
||||||
|
CI_POLL_INTERVAL=15 # seconds between CI status polls
|
||||||
|
CI_INITIAL_DELAY=20 # seconds to wait before first CI check
|
||||||
|
CI_MAX_WAIT=600 # maximum seconds to wait for CI (10 minutes)
|
||||||
|
|
||||||
# Get script directory and project root
|
# Get script directory and project root
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
# Check for gh CLI
|
# Detect git remote name (prefer 'origin', fall back to 'github')
|
||||||
|
detect_remote() {
|
||||||
|
if git remote | grep -q '^origin$'; then
|
||||||
|
echo "origin"
|
||||||
|
elif git remote | grep -q '^github$'; then
|
||||||
|
echo "github"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: No 'origin' or 'github' remote found.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
GIT_REMOTE=$(detect_remote)
|
||||||
|
|
||||||
|
# Wait for CI checks on a PR with retry logic
|
||||||
|
# GitHub Actions can take 10-30 seconds before checks are reported.
|
||||||
|
# This function polls until checks appear and then watches them.
|
||||||
|
wait_for_ci() {
|
||||||
|
local pr_number="$1"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
echo -e "${BLUE}Waiting ${CI_INITIAL_DELAY}s for CI checks to be registered...${NC}"
|
||||||
|
sleep "${CI_INITIAL_DELAY}"
|
||||||
|
elapsed=$CI_INITIAL_DELAY
|
||||||
|
|
||||||
|
while [[ $elapsed -lt $CI_MAX_WAIT ]]; do
|
||||||
|
# Check if any checks have been reported
|
||||||
|
local check_output
|
||||||
|
check_output=$(gh pr checks "${pr_number}" --repo "${GITHUB_REPO}" 2>&1) || true
|
||||||
|
|
||||||
|
if echo "$check_output" | grep -q "no checks reported"; then
|
||||||
|
echo -e "${YELLOW}No checks reported yet (${elapsed}s elapsed). Retrying in ${CI_POLL_INTERVAL}s...${NC}"
|
||||||
|
sleep "${CI_POLL_INTERVAL}"
|
||||||
|
elapsed=$((elapsed + CI_POLL_INTERVAL))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Checks are registered — use --watch to wait for completion
|
||||||
|
echo -e "${BLUE}CI checks registered. Watching for completion...${NC}"
|
||||||
|
if gh pr checks "${pr_number}" --repo "${GITHUB_REPO}" --watch; then
|
||||||
|
echo -e "${GREEN}CI checks passed!${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}CI checks failed!${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${RED}Timed out waiting for CI checks after ${CI_MAX_WAIT}s.${NC}"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Preflight checks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v gh &> /dev/null; then
|
if ! command -v gh &> /dev/null; then
|
||||||
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
||||||
echo "Install it with: brew install gh"
|
echo "Install it with: brew install gh"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check gh authentication
|
|
||||||
if ! gh auth status &> /dev/null; then
|
if ! gh auth status &> /dev/null; then
|
||||||
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
||||||
echo "Run: gh auth login"
|
echo "Run: gh auth login"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for uncommitted changes
|
|
||||||
if [[ -n $(git status --porcelain) ]]; then
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
||||||
git status --short
|
git status --short
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Make sure we're on main and up to date
|
# ─── Determine version ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo -e "${BLUE}Updating main branch...${NC}"
|
echo -e "${BLUE}Updating main branch...${NC}"
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
git pull "${GIT_REMOTE}" main
|
||||||
|
|
||||||
# Get current version from backend/package.json
|
|
||||||
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
|
||||||
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
||||||
|
|
||||||
# Calculate new version
|
|
||||||
if [[ -z "$1" ]]; then
|
if [[ -z "$1" ]]; then
|
||||||
echo -e "${RED}Usage: $0 <patch|minor|major|x.y.z>${NC}"
|
echo -e "${RED}Usage: $0 <patch|minor|major|x.y.z>${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -79,7 +133,6 @@ case "$1" in
|
|||||||
NEW_VERSION="$((major + 1)).0.0"
|
NEW_VERSION="$((major + 1)).0.0"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
# Assume explicit version (validate format)
|
|
||||||
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
echo -e "${RED}Invalid version format. Use: x.y.z${NC}"
|
echo -e "${RED}Invalid version format. Use: x.y.z${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -88,45 +141,31 @@ case "$1" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo -e "${GREEN}New version: ${YELLOW}v${NEW_VERSION}${NC}"
|
echo -e "${GREEN}Releasing: ${YELLOW}v${CURRENT_VERSION} → v${NEW_VERSION}${NC}"
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Confirm
|
# ─── Create release branch and PR ────────────────────────────────────────────
|
||||||
read -p "Release v${NEW_VERSION}? (y/N) " -n 1 -r
|
|
||||||
echo ""
|
|
||||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
echo "Aborted."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Branch name for the release
|
|
||||||
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
||||||
|
|
||||||
# Check if branch already exists
|
|
||||||
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
||||||
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
||||||
git branch -D "${RELEASE_BRANCH}"
|
git branch -D "${RELEASE_BRANCH}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create release branch
|
|
||||||
echo -e "${BLUE}Creating release branch...${NC}"
|
echo -e "${BLUE}Creating release branch...${NC}"
|
||||||
git checkout -b "${RELEASE_BRANCH}"
|
git checkout -b "${RELEASE_BRANCH}"
|
||||||
|
|
||||||
# Update version in package.json files
|
|
||||||
echo -e "${BLUE}Updating package.json files...${NC}"
|
echo -e "${BLUE}Updating package.json files...${NC}"
|
||||||
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
|
||||||
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" frontend/package.json 2>/dev/null || true
|
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" frontend/package.json 2>/dev/null || true
|
||||||
|
|
||||||
# Commit version bump
|
|
||||||
echo -e "${BLUE}Committing version bump...${NC}"
|
echo -e "${BLUE}Committing version bump...${NC}"
|
||||||
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
||||||
git commit -m "chore: release v${NEW_VERSION}"
|
git commit -m "chore: release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Push branch to GitHub
|
echo -e "${BLUE}Pushing release branch...${NC}"
|
||||||
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
|
git push -u "${GIT_REMOTE}" "${RELEASE_BRANCH}"
|
||||||
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
|
|
||||||
|
|
||||||
# Create PR
|
|
||||||
echo -e "${BLUE}Creating Pull Request...${NC}"
|
echo -e "${BLUE}Creating Pull Request...${NC}"
|
||||||
PR_URL=$(gh pr create \
|
PR_URL=$(gh pr create \
|
||||||
--repo "${GITHUB_REPO}" \
|
--repo "${GITHUB_REPO}" \
|
||||||
@@ -136,53 +175,45 @@ PR_URL=$(gh pr create \
|
|||||||
|
|
||||||
Automated version bump for release v${NEW_VERSION}.
|
Automated version bump for release v${NEW_VERSION}.
|
||||||
|
|
||||||
This PR was created by the release script." \
|
This PR was created by the release script.")
|
||||||
2>&1)
|
|
||||||
|
|
||||||
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
||||||
|
|
||||||
# Extract PR number
|
|
||||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||||
|
|
||||||
# Wait for CI checks
|
# ─── Wait for CI and merge ────────────────────────────────────────────────────
|
||||||
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
|
|
||||||
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
|
if ! wait_for_ci "${PR_NUMBER}"; then
|
||||||
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
|
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
|
||||||
|
echo -e "${RED}Release branch '${RELEASE_BRANCH}' and PR #${PR_NUMBER} are still open.${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}CI checks passed!${NC}"
|
echo -e "${BLUE}Merging PR #${PR_NUMBER}...${NC}"
|
||||||
|
|
||||||
# Merge PR
|
|
||||||
echo -e "${BLUE}Merging PR...${NC}"
|
|
||||||
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
||||||
|
|
||||||
# Switch back to main and pull
|
echo -e "${BLUE}Updating main branch...${NC}"
|
||||||
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
|
|
||||||
git checkout main
|
git checkout main
|
||||||
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true
|
git pull "${GIT_REMOTE}" main
|
||||||
|
|
||||||
|
# ─── Create and push signed tag ──────────────────────────────────────────────
|
||||||
|
|
||||||
# Check if tag exists and delete it
|
|
||||||
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
|
||||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
||||||
git tag -d "v${NEW_VERSION}"
|
git tag -d "v${NEW_VERSION}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if remote tag exists
|
if git ls-remote --tags "${GIT_REMOTE}" "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
||||||
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
|
|
||||||
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
|
||||||
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
|
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
|
||||||
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
git push "${GIT_REMOTE}" ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||||
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create signed tag
|
|
||||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||||
|
|
||||||
# Push tag
|
echo -e "${BLUE}Pushing tag...${NC}"
|
||||||
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
|
git push "${GIT_REMOTE}" "v${NEW_VERSION}"
|
||||||
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
|
|
||||||
|
# ─── Done ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||||
|
|||||||
Reference in New Issue
Block a user