Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c9f1a7595 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c |
@@ -1,11 +1,17 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT
|
## VERY IMPORTANT - Prioritized Constraints
|
||||||
|
|
||||||
|
**First: Update Memory and Reports**
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- If `doku/memory_notes.md` is missing, create it immediately.
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- If `doku/report.md` is missing, create it immediately.
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
|
**Second: Follow Governance Rules**
|
||||||
|
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|||||||
+15
-1
@@ -107,4 +107,18 @@ docs/SPEC_KIT.md
|
|||||||
.github/skills/nodejs-backend-patterns/
|
.github/skills/nodejs-backend-patterns/
|
||||||
.github/skills/nodejs-best-practices/
|
.github/skills/nodejs-best-practices/
|
||||||
.github/skills/seo/
|
.github/skills/seo/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
|
||||||
|
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||||
|
.github/agents/copilot-instructions.md
|
||||||
|
.github/agents/gsd-*.agent.md
|
||||||
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
|
.github/agents/speckit.*.agent.md
|
||||||
|
.github/get-shit-done/
|
||||||
|
.github/gsd-file-manifest.json
|
||||||
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/gsd-*/
|
||||||
|
.planning/
|
||||||
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
ops/medtest/
|
||||||
@@ -378,6 +378,14 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
|
|||||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||||
|
|
||||||
|
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
|
||||||
|
|
||||||
|
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
|
||||||
|
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
|
||||||
|
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
|
||||||
|
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
|
||||||
|
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
|
||||||
|
|
||||||
Useful local commands:
|
Useful local commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { and, desc, eq } from "drizzle-orm";
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
import { medications, refillHistory } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
@@ -196,22 +195,13 @@ export async function refillRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refillBaselineAt = new Date();
|
const refillBaselineAt = new Date();
|
||||||
const [settings] = await db
|
const baselineStockBeforeRefill = isAmountBased
|
||||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
.from(userSettings)
|
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
.where(eq(userSettings.userId, userId));
|
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||||
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
|
||||||
const currentStockAtRefill = computeMedicationCurrentStock({
|
|
||||||
medication: med,
|
|
||||||
doses,
|
|
||||||
stockCalculationMode,
|
|
||||||
nowMs: refillBaselineAt.getTime(),
|
|
||||||
});
|
|
||||||
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
|
|
||||||
|
|
||||||
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||||
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||||
let newPackCount = med.packCount + effectivePacksAdded;
|
let newPackCount = med.packCount + effectivePacksAdded;
|
||||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ const reportDataResponseSchema = {
|
|||||||
properties: {
|
properties: {
|
||||||
packsAdded: { type: "integer" },
|
packsAdded: { type: "integer" },
|
||||||
loosePillsAdded: { type: "integer" },
|
loosePillsAdded: { type: "integer" },
|
||||||
quantityAdded: { type: "integer" },
|
|
||||||
usedPrescription: { type: "boolean" },
|
usedPrescription: { type: "boolean" },
|
||||||
refillDate: { type: "string", format: "date-time" },
|
refillDate: { type: "string", format: "date-time" },
|
||||||
},
|
},
|
||||||
@@ -116,16 +115,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Verify all medications belong to this user
|
// Verify all medications belong to this user
|
||||||
const userMeds = await db
|
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||||
.select({
|
|
||||||
id: medications.id,
|
|
||||||
packageType: medications.packageType,
|
|
||||||
blistersPerPack: medications.blistersPerPack,
|
|
||||||
pillsPerBlister: medications.pillsPerBlister,
|
|
||||||
})
|
|
||||||
.from(medications)
|
|
||||||
.where(eq(medications.userId, userId));
|
|
||||||
const medMap = new Map(userMeds.map((med) => [med.id, med]));
|
|
||||||
const userMedIds = new Set(userMeds.map((m) => m.id));
|
const userMedIds = new Set(userMeds.map((m) => m.id));
|
||||||
|
|
||||||
for (const id of medicationIds) {
|
for (const id of medicationIds) {
|
||||||
@@ -169,13 +159,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
dosesSkipped: number;
|
dosesSkipped: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
refills: {
|
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||||
packsAdded: number;
|
|
||||||
loosePillsAdded: number;
|
|
||||||
quantityAdded: number;
|
|
||||||
usedPrescription: boolean;
|
|
||||||
refillDate: string;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
@@ -186,9 +170,6 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const skippedDoses = doses.filter((d) => d.dismissed);
|
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
const medication = medMap.get(medId);
|
|
||||||
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
|
|
||||||
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
|
||||||
|
|
||||||
// Get refills for this medication scoped to the authenticated user.
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
const refills = await db
|
const refills = await db
|
||||||
@@ -205,7 +186,6 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
refills: refills.map((r) => ({
|
refills: refills.map((r) => ({
|
||||||
packsAdded: r.packsAdded,
|
packsAdded: r.packsAdded,
|
||||||
loosePillsAdded: r.loosePillsAdded,
|
loosePillsAdded: r.loosePillsAdded,
|
||||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
|
||||||
usedPrescription: r.usedPrescription ?? false,
|
usedPrescription: r.usedPrescription ?? false,
|
||||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 2,
|
packsAdded: 2,
|
||||||
loosePillsAdded: 5,
|
loosePillsAdded: 5,
|
||||||
quantityAdded: 7,
|
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -377,7 +376,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data[medId].refills[0]).toMatchObject({
|
expect(data[medId].refills[0]).toMatchObject({
|
||||||
packsAdded: 1,
|
packsAdded: 1,
|
||||||
loosePillsAdded: 0,
|
loosePillsAdded: 0,
|
||||||
quantityAdded: 1,
|
|
||||||
usedPrescription: false,
|
usedPrescription: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2445,81 +2443,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.stockAdjustment).toBe(0);
|
expect(med.stockAdjustment).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
|
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/medications",
|
|
||||||
payload: {
|
|
||||||
name: "Liquid Stale Client Stock Correction",
|
|
||||||
medicationForm: "liquid",
|
|
||||||
packageType: "liquid_container",
|
|
||||||
doseUnit: "ml",
|
|
||||||
packCount: 7,
|
|
||||||
packageAmountValue: 150,
|
|
||||||
packageAmountUnit: "ml",
|
|
||||||
blistersPerPack: 1,
|
|
||||||
pillsPerBlister: 1,
|
|
||||||
totalPills: 1050,
|
|
||||||
looseTablets: 1050,
|
|
||||||
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(createResponse.statusCode).toBe(200);
|
|
||||||
const medId = createResponse.json().id;
|
|
||||||
|
|
||||||
const correctionResponse = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/medications/${medId}/stock-adjustment`,
|
|
||||||
payload: {
|
|
||||||
stockAdjustment: 0,
|
|
||||||
packCount: 1,
|
|
||||||
totalPills: 150,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(correctionResponse.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
|
|
||||||
expect(afterCorrectionResponse.statusCode).toBe(200);
|
|
||||||
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
|
||||||
expect(correctedMed).toBeTruthy();
|
|
||||||
expect(correctedMed.packCount).toBe(1);
|
|
||||||
expect(correctedMed.totalPills).toBe(150);
|
|
||||||
expect(correctedMed.looseTablets).toBe(150);
|
|
||||||
expect(correctedMed.stockAdjustment).toBe(0);
|
|
||||||
|
|
||||||
const refillResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
|
||||||
});
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
|
||||||
const refillData = refillResponse.json();
|
|
||||||
expect(refillData.refill.quantityAdded).toBe(150);
|
|
||||||
expect(refillData.newStock.packCount).toBe(2);
|
|
||||||
expect(refillData.newStock.looseTablets).toBe(300);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(300);
|
|
||||||
|
|
||||||
const historyResponse = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/medications/${medId}/refills`,
|
|
||||||
});
|
|
||||||
expect(historyResponse.statusCode).toBe(200);
|
|
||||||
expect(historyResponse.json()[0].quantityAdded).toBe(150);
|
|
||||||
|
|
||||||
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
|
|
||||||
expect(afterRefillResponse.statusCode).toBe(200);
|
|
||||||
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
|
||||||
expect(refilledMed).toBeTruthy();
|
|
||||||
expect(refilledMed.packCount).toBe(2);
|
|
||||||
expect(refilledMed.totalPills).toBe(300);
|
|
||||||
expect(refilledMed.looseTablets).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should persist stockAdjustment in GET /medications", async () => {
|
it("should persist stockAdjustment in GET /medications", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3125,47 +3048,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function expectRefillInvariants({
|
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill,
|
|
||||||
expectedQuantityAdded,
|
|
||||||
expectedPacksAdded,
|
|
||||||
expectedAmountPerPackage,
|
|
||||||
}: {
|
|
||||||
medId: number;
|
|
||||||
refillData: {
|
|
||||||
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
|
|
||||||
newStock: { packCount: number; totalPills: number; looseTablets: number };
|
|
||||||
};
|
|
||||||
visibleStockBeforeRefill: number;
|
|
||||||
expectedQuantityAdded: number;
|
|
||||||
expectedPacksAdded: number;
|
|
||||||
expectedAmountPerPackage?: number;
|
|
||||||
}) {
|
|
||||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
|
||||||
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
|
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
|
|
||||||
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
|
|
||||||
|
|
||||||
const historyResponse = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/medications/${medId}/refills`,
|
|
||||||
});
|
|
||||||
expect(historyResponse.statusCode).toBe(200);
|
|
||||||
expect(historyResponse.json()[0]).toMatchObject({
|
|
||||||
packsAdded: expectedPacksAdded,
|
|
||||||
quantityAdded: expectedQuantityAdded,
|
|
||||||
totalPillsAdded: expectedQuantityAdded,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (expectedAmountPerPackage) {
|
|
||||||
expect(refillData.newStock.packCount).toBe(
|
|
||||||
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should create and return bottle type medication", async () => {
|
it("should create and return bottle type medication", async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3359,196 +3241,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
name: "bottle",
|
|
||||||
payload: {
|
|
||||||
...bottleMedication,
|
|
||||||
totalPills: 100,
|
|
||||||
looseTablets: 10,
|
|
||||||
},
|
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
|
|
||||||
expectedVisibleStockBeforeRefill: 4,
|
|
||||||
expectedQuantityAdded: 100,
|
|
||||||
expectedResponsePacksAdded: 0,
|
|
||||||
expectedPackCount: 0,
|
|
||||||
expectedLooseTablets: 104,
|
|
||||||
expectedTotalPills: 104,
|
|
||||||
expectedPersistedTotalPills: 100,
|
|
||||||
expectedStockAdjustment: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blister",
|
|
||||||
payload: {
|
|
||||||
...blisterMedication,
|
|
||||||
packCount: 1,
|
|
||||||
blistersPerPack: 1,
|
|
||||||
pillsPerBlister: 10,
|
|
||||||
looseTablets: 0,
|
|
||||||
},
|
|
||||||
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
|
||||||
expectedVisibleStockBeforeRefill: 4,
|
|
||||||
expectedQuantityAdded: 10,
|
|
||||||
expectedResponsePacksAdded: 1,
|
|
||||||
expectedPackCount: 2,
|
|
||||||
expectedLooseTablets: 0,
|
|
||||||
expectedTotalPills: 14,
|
|
||||||
expectedPersistedTotalPills: null,
|
|
||||||
expectedStockAdjustment: -6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "liquid_container",
|
|
||||||
payload: {
|
|
||||||
...liquidContainerMedication,
|
|
||||||
packCount: 1,
|
|
||||||
packageAmountValue: 100,
|
|
||||||
packageAmountUnit: "ml",
|
|
||||||
totalPills: 10,
|
|
||||||
looseTablets: 10,
|
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
|
||||||
},
|
|
||||||
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
|
||||||
expectedVisibleStockBeforeRefill: 4,
|
|
||||||
expectedQuantityAdded: 100,
|
|
||||||
expectedResponsePacksAdded: 1,
|
|
||||||
expectedAmountPerPackage: 100,
|
|
||||||
expectedPackCount: 2,
|
|
||||||
expectedLooseTablets: 104,
|
|
||||||
expectedTotalPills: 104,
|
|
||||||
expectedPersistedTotalPills: 104,
|
|
||||||
expectedStockAdjustment: 0,
|
|
||||||
},
|
|
||||||
])("should refill from current visible stock after prior consumption for $name", async ({
|
|
||||||
payload,
|
|
||||||
refillPayload,
|
|
||||||
expectedVisibleStockBeforeRefill,
|
|
||||||
expectedQuantityAdded,
|
|
||||||
expectedResponsePacksAdded,
|
|
||||||
expectedAmountPerPackage,
|
|
||||||
expectedPackCount,
|
|
||||||
expectedLooseTablets,
|
|
||||||
expectedTotalPills,
|
|
||||||
expectedPersistedTotalPills,
|
|
||||||
expectedStockAdjustment,
|
|
||||||
}) => {
|
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/medications",
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
expect(createResponse.statusCode).toBe(200);
|
|
||||||
const medId = createResponse.json().id;
|
|
||||||
|
|
||||||
for (let day = 1; day <= 6; day += 1) {
|
|
||||||
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
|
|
||||||
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
|
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
|
|
||||||
VALUES (?, ?, ?, 0)`,
|
|
||||||
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const refillResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: refillPayload,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
|
||||||
const refillData = refillResponse.json();
|
|
||||||
await expectRefillInvariants({
|
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
|
||||||
expectedQuantityAdded,
|
|
||||||
expectedPacksAdded: expectedResponsePacksAdded,
|
|
||||||
expectedAmountPerPackage,
|
|
||||||
});
|
|
||||||
expect(refillData.newStock.packCount).toBe(expectedPackCount);
|
|
||||||
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
|
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
|
||||||
expect(medsResponse.statusCode).toBe(200);
|
|
||||||
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
|
||||||
expect(med).toBeTruthy();
|
|
||||||
expect(med.packCount).toBe(expectedPackCount);
|
|
||||||
expect(med.looseTablets).toBe(expectedLooseTablets);
|
|
||||||
expect(med.totalPills).toBe(expectedPersistedTotalPills);
|
|
||||||
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should refill tube stock from the corrected visible baseline", async () => {
|
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/medications",
|
|
||||||
payload: {
|
|
||||||
...tubeMedication,
|
|
||||||
packCount: 1,
|
|
||||||
packageAmountValue: 80,
|
|
||||||
packageAmountUnit: "g",
|
|
||||||
totalPills: 10,
|
|
||||||
looseTablets: 10,
|
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(createResponse.statusCode).toBe(200);
|
|
||||||
const medId = createResponse.json().id;
|
|
||||||
|
|
||||||
const correctionResponse = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/medications/${medId}/stock-adjustment`,
|
|
||||||
payload: {
|
|
||||||
stockAdjustment: -6,
|
|
||||||
looseTablets: 10,
|
|
||||||
totalPills: 10,
|
|
||||||
packageAmountValue: 80,
|
|
||||||
packCount: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(correctionResponse.statusCode).toBe(200);
|
|
||||||
|
|
||||||
const refillResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 1, loosePillsAdded: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
|
||||||
const refillData = refillResponse.json();
|
|
||||||
await expectRefillInvariants({
|
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: 4,
|
|
||||||
expectedQuantityAdded: 80,
|
|
||||||
expectedPacksAdded: 1,
|
|
||||||
expectedAmountPerPackage: 80,
|
|
||||||
});
|
|
||||||
expect(refillData.newStock.packCount).toBe(2);
|
|
||||||
expect(refillData.newStock.looseTablets).toBe(84);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(84);
|
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
|
||||||
expect(medsResponse.statusCode).toBe(200);
|
|
||||||
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
|
|
||||||
expect(med).toBeTruthy();
|
|
||||||
expect(med.packCount).toBe(2);
|
|
||||||
expect(med.looseTablets).toBe(84);
|
|
||||||
expect(med.totalPills).toBe(84);
|
|
||||||
expect(med.stockAdjustment).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
it("should calculate correct refill totalPillsAdded for blister type", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -3580,11 +3272,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3607,15 +3294,9 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
await expectRefillInvariants({
|
expect(refillData.refill.packsAdded).toBe(1);
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: 180,
|
|
||||||
expectedQuantityAdded: 180,
|
|
||||||
expectedPacksAdded: 1,
|
|
||||||
expectedAmountPerPackage: 180,
|
|
||||||
});
|
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(180);
|
||||||
expect(refillData.newStock.totalPills).toBe(360);
|
expect(refillData.newStock.totalPills).toBe(360);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
@@ -3626,54 +3307,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(med.looseTablets).toBe(360);
|
expect(med.looseTablets).toBe(360);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
|
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/medications",
|
|
||||||
payload: {
|
|
||||||
...liquidContainerMedication,
|
|
||||||
packCount: 0,
|
|
||||||
packageAmountValue: 150,
|
|
||||||
totalPills: 300,
|
|
||||||
looseTablets: 300,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(createResponse.statusCode).toBe(200);
|
|
||||||
const medId = createResponse.json().id;
|
|
||||||
|
|
||||||
const refillResponse = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: `/medications/${medId}/refill`,
|
|
||||||
payload: { packsAdded: 5, loosePillsAdded: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
|
||||||
const refillData = refillResponse.json();
|
|
||||||
await expectRefillInvariants({
|
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: 300,
|
|
||||||
expectedQuantityAdded: 750,
|
|
||||||
expectedPacksAdded: 5,
|
|
||||||
expectedAmountPerPackage: 150,
|
|
||||||
});
|
|
||||||
expect(refillData.newStock.packCount).toBe(7);
|
|
||||||
expect(refillData.newStock.totalPills).toBe(1050);
|
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
|
||||||
expect(medsResponse.statusCode).toBe(200);
|
|
||||||
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
|
|
||||||
expect(med).toBeTruthy();
|
|
||||||
expect(med.packCount).toBe(7);
|
|
||||||
expect(med.totalPills).toBe(1050);
|
|
||||||
expect(med.looseTablets).toBe(1050);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "liquid_container",
|
name: "liquid_container",
|
||||||
@@ -3690,12 +3323,10 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||||
expectedVisibleStockBeforeRefill: 180,
|
|
||||||
expectedPacksAdded: 1,
|
expectedPacksAdded: 1,
|
||||||
expectedLooseAdded: 180,
|
expectedLooseAdded: 180,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 360,
|
expectedTotalPills: 360,
|
||||||
expectedAmountPerPackage: 180,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tube",
|
name: "tube",
|
||||||
@@ -3707,28 +3338,19 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
prescriptionLowRefillThreshold: 1,
|
prescriptionLowRefillThreshold: 1,
|
||||||
},
|
},
|
||||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||||
expectedVisibleStockBeforeRefill: 80,
|
|
||||||
expectedPacksAdded: 2,
|
expectedPacksAdded: 2,
|
||||||
expectedLooseAdded: 80,
|
expectedLooseAdded: 80,
|
||||||
expectedRemainingRefills: 1,
|
expectedRemainingRefills: 1,
|
||||||
expectedTotalPills: 160,
|
expectedTotalPills: 160,
|
||||||
expectedAmountPerPackage: 40,
|
|
||||||
},
|
},
|
||||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||||
payload,
|
payload,
|
||||||
refillPayload,
|
refillPayload,
|
||||||
expectedVisibleStockBeforeRefill,
|
|
||||||
expectedPacksAdded,
|
expectedPacksAdded,
|
||||||
expectedLooseAdded,
|
expectedLooseAdded,
|
||||||
expectedRemainingRefills,
|
expectedRemainingRefills,
|
||||||
expectedTotalPills,
|
expectedTotalPills,
|
||||||
expectedAmountPerPackage,
|
|
||||||
}) => {
|
}) => {
|
||||||
await testClient.execute({
|
|
||||||
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
|
|
||||||
args: [userId],
|
|
||||||
});
|
|
||||||
|
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/medications",
|
url: "/medications",
|
||||||
@@ -3745,17 +3367,8 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
await expectRefillInvariants({
|
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
|
||||||
expectedQuantityAdded: expectedLooseAdded,
|
|
||||||
expectedPacksAdded,
|
|
||||||
expectedAmountPerPackage,
|
|
||||||
});
|
|
||||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
|
||||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||||
expect(refillData.prescription.used).toBe(true);
|
expect(refillData.prescription.used).toBe(true);
|
||||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||||
@@ -3769,7 +3382,6 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(historyResponse.json()[0]).toMatchObject({
|
expect(historyResponse.json()[0]).toMatchObject({
|
||||||
packsAdded: expectedPacksAdded,
|
packsAdded: expectedPacksAdded,
|
||||||
loosePillsAdded: expectedLooseAdded,
|
loosePillsAdded: expectedLooseAdded,
|
||||||
quantityAdded: expectedLooseAdded,
|
|
||||||
usedPrescription: true,
|
usedPrescription: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3791,15 +3403,9 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
expect(refillResponse.statusCode).toBe(200);
|
expect(refillResponse.statusCode).toBe(200);
|
||||||
const refillData = refillResponse.json();
|
const refillData = refillResponse.json();
|
||||||
await expectRefillInvariants({
|
expect(refillData.refill.packsAdded).toBe(1);
|
||||||
medId,
|
|
||||||
refillData,
|
|
||||||
visibleStockBeforeRefill: 80,
|
|
||||||
expectedQuantityAdded: 40,
|
|
||||||
expectedPacksAdded: 1,
|
|
||||||
expectedAmountPerPackage: 40,
|
|
||||||
});
|
|
||||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||||
|
expect(refillData.refill.totalPillsAdded).toBe(40);
|
||||||
expect(refillData.newStock.totalPills).toBe(120);
|
expect(refillData.newStock.totalPills).toBe(120);
|
||||||
|
|
||||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Scope and behavior:
|
|||||||
|
|
||||||
- These values are applied only when a user's settings are created for the first time.
|
- These values are applied only when a user's settings are created for the first time.
|
||||||
- After that, values stored in the database are used and take precedence.
|
- After that, values stored in the database are used and take precedence.
|
||||||
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
|
||||||
|
|
||||||
## Email Defaults
|
## Email Defaults
|
||||||
|
|
||||||
@@ -47,6 +46,6 @@ Scope and behavior:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
||||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||||
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ function AppContent() {
|
|||||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/medications" element={<MedicationsPage />} />
|
<Route path="/medications" element={<MedicationsPage />} />
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markDoseTaken(doseId: string) {
|
async function markDoseTaken(doseId: string) {
|
||||||
if (dismissedDoses.has(doseId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasTaken = takenDoses.has(doseId);
|
const wasTaken = takenDoses.has(doseId);
|
||||||
const wasSkipped = dismissedDoses.has(doseId);
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
@@ -466,7 +462,7 @@ export function SharedSchedule() {
|
|||||||
<button
|
<button
|
||||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||||
onClick={() => markDoseTaken(options.doseId)}
|
onClick={() => markDoseTaken(options.doseId)}
|
||||||
disabled={options.isEmpty || options.isSkipped}
|
disabled={options.isEmpty}
|
||||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
@@ -476,7 +472,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
const skipButton = options.isSkipped ? (
|
const skipButton = options.isSkipped ? (
|
||||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
||||||
<span aria-hidden="true">↩</span>
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -257,8 +257,10 @@ export function MedicationsPage() {
|
|||||||
useUnsavedChangesWarning(formChanged);
|
useUnsavedChangesWarning(formChanged);
|
||||||
|
|
||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
||||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
const [pendingEditTransition, setPendingEditTransition] = useState(
|
||||||
|
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
||||||
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
@@ -269,9 +271,23 @@ export function MedicationsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showEditModalRef.current = showEditModal;
|
showEditModalRef.current = showEditModal;
|
||||||
}, [showEditModal]);
|
}, [showEditModal]);
|
||||||
const processedEditMedIdRef = useRef<string | null>(null);
|
const processedMedicationLinkRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
|
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
|
||||||
|
const viewMedId = params.get("viewMedId");
|
||||||
|
if (viewMedId) {
|
||||||
|
return { mode: "view" as const, linkedMedId: viewMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMedId = params.get("editMedId");
|
||||||
|
if (editMedId) {
|
||||||
|
return { mode: "edit" as const, linkedMedId: editMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: null, linkedMedId: null };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync formChanged state to the global context for navigation blocking
|
// Sync formChanged state to the global context for navigation blocking
|
||||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -819,12 +835,13 @@ export function MedicationsPage() {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearEditMedIdParam = useCallback(() => {
|
const clearMedicationLinkParams = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prevParams) => {
|
(prevParams) => {
|
||||||
if (!prevParams.has("editMedId")) return prevParams;
|
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
||||||
const nextParams = new URLSearchParams(prevParams);
|
const nextParams = new URLSearchParams(prevParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
return nextParams;
|
return nextParams;
|
||||||
},
|
},
|
||||||
{ replace: true }
|
{ replace: true }
|
||||||
@@ -848,7 +865,7 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
|
|||||||
if (shouldCloseMobileModal) {
|
if (shouldCloseMobileModal) {
|
||||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
const currentParams = new URLSearchParams(window.location.search);
|
||||||
|
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
}
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
resetMedicationEnrichment();
|
resetMedicationEnrichment();
|
||||||
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
}, [
|
||||||
|
showObsoleteConfirm,
|
||||||
|
showDeleteConfirm,
|
||||||
|
showEditModal,
|
||||||
|
viewMode,
|
||||||
|
formChanged,
|
||||||
|
resetForm,
|
||||||
|
clearMedicationLinkParams,
|
||||||
|
getMedicationLinkState,
|
||||||
|
]);
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
|
|||||||
}, [activeMeds, editingId]);
|
}, [activeMeds, editingId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editMedId = searchParams.get("editMedId");
|
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
||||||
if (!editMedId) {
|
if (!linkedMedId || !linkMode) {
|
||||||
processedEditMedIdRef.current = null;
|
processedMedicationLinkRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (processedEditMedIdRef.current === editMedId) return;
|
const linkKey = `${linkMode}:${linkedMedId}`;
|
||||||
const parsedMedId = Number.parseInt(editMedId, 10);
|
if (processedMedicationLinkRef.current === linkKey) return;
|
||||||
|
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
||||||
if (Number.isNaN(parsedMedId)) return;
|
if (Number.isNaN(parsedMedId)) return;
|
||||||
const medicationToEdit =
|
const medicationToEdit =
|
||||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||||
if (!medicationToEdit) return;
|
if (!medicationToEdit) return;
|
||||||
|
|
||||||
processedEditMedIdRef.current = editMedId;
|
processedMedicationLinkRef.current = linkKey;
|
||||||
|
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(linkMode === "view");
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
|
|
||||||
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../pages", () => ({
|
vi.mock("../pages", () => ({
|
||||||
DashboardPage: () => <div>dashboard-page</div>,
|
DashboardPage: () => {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>dashboard-page</span>
|
||||||
|
<span data-testid="dashboard-location-search">{location.search}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
MedicationsPage: () => <div>medications-page</div>,
|
MedicationsPage: () => <div>medications-page</div>,
|
||||||
PlannerPage: () => <div>planner-page</div>,
|
PlannerPage: () => <div>planner-page</div>,
|
||||||
SchedulePage: () => <div>schedule-page</div>,
|
SchedulePage: () => <div>schedule-page</div>,
|
||||||
@@ -265,6 +273,19 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves notification query params when redirecting root to dashboard", () => {
|
||||||
|
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/${search}`]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders initializing state when auth state is missing", () => {
|
it("renders initializing state when auth state is missing", () => {
|
||||||
authMock = {
|
authMock = {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
|
|||||||
oidcProviderName: "",
|
oidcProviderName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
|||||||
@@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens read-only view from viewMedId query parameter", async () => {
|
||||||
|
const startEdit = vi.fn();
|
||||||
|
mockFormHookValue = createMockFormHook({ startEdit });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
|
||||||
|
|
||||||
|
renderPage("/medications?viewMedId=1");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(startEdit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("common.close")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
||||||
const startEdit = vi.fn();
|
const startEdit = vi.fn();
|
||||||
const resetForm = vi.fn();
|
const resetForm = vi.fn();
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { existsSync, readFileSync } from "fs";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
function parseCsvEnv(value: string | undefined, fallback: string[]) {
|
||||||
|
const entries = value
|
||||||
|
?.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
return entries && entries.length > 0 ? entries : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPort(value: string | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Read version from package.json at build time
|
// Read version from package.json at build time
|
||||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
@@ -9,6 +27,19 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
|||||||
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
||||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||||
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
||||||
|
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
|
||||||
|
const hmrHost = process.env.VITE_HMR_HOST?.trim();
|
||||||
|
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
|
||||||
|
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
|
||||||
|
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
|
||||||
|
const hmr = hmrHost
|
||||||
|
? {
|
||||||
|
host: hmrHost,
|
||||||
|
protocol: hmrProtocol ?? "wss",
|
||||||
|
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
|
||||||
|
port: hmrPort ?? 5173,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -19,6 +50,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
allowedHosts,
|
||||||
|
hmr,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: backendTarget,
|
target: backendTarget,
|
||||||
|
|||||||
Reference in New Issue
Block a user