Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 693922fff1 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c |
@@ -1,11 +1,17 @@
|
||||
# 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.
|
||||
- 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.
|
||||
- If `doku/report.md` is missing, create it immediately.
|
||||
- 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.
|
||||
|
||||
## Required Startup Steps
|
||||
|
||||
+15
-1
@@ -107,4 +107,18 @@ docs/SPEC_KIT.md
|
||||
.github/skills/nodejs-backend-patterns/
|
||||
.github/skills/nodejs-best-practices/
|
||||
.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)
|
||||
- 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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, refillHistory } from "../db/schema.js";
|
||||
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -195,13 +196,22 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const refillBaselineAt = new Date();
|
||||
const baselineStockBeforeRefill = isAmountBased
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||
const [settings] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
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 persisted stock baseline and resets
|
||||
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
||||
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
||||
let newPackCount = med.packCount + effectivePacksAdded;
|
||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||
|
||||
@@ -65,6 +65,7 @@ const reportDataResponseSchema = {
|
||||
properties: {
|
||||
packsAdded: { type: "integer" },
|
||||
loosePillsAdded: { type: "integer" },
|
||||
quantityAdded: { type: "integer" },
|
||||
usedPrescription: { type: "boolean" },
|
||||
refillDate: { type: "string", format: "date-time" },
|
||||
},
|
||||
@@ -115,7 +116,16 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
: null;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
|
||||
const userMeds = await db
|
||||
.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));
|
||||
|
||||
for (const id of medicationIds) {
|
||||
@@ -159,7 +169,13 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
}
|
||||
> = {};
|
||||
|
||||
@@ -170,6 +186,9 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const skippedDoses = doses.filter((d) => d.dismissed);
|
||||
|
||||
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.
|
||||
const refills = await db
|
||||
@@ -186,6 +205,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
refills: refills.map((r) => ({
|
||||
packsAdded: r.packsAdded,
|
||||
loosePillsAdded: r.loosePillsAdded,
|
||||
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
|
||||
usedPrescription: r.usedPrescription ?? false,
|
||||
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
|
||||
})),
|
||||
|
||||
@@ -583,7 +583,7 @@ export async function sendShoutrrrNotification(
|
||||
urlStr: string,
|
||||
title: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
try {
|
||||
if (urlStr.startsWith("pushover://")) {
|
||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||
@@ -736,7 +736,7 @@ export async function sendShoutrrrNotification(
|
||||
}
|
||||
|
||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
@@ -823,7 +823,17 @@ export async function sendShoutrrrNotification(
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
let providerMessageId: string | undefined;
|
||||
if (isNtfy) {
|
||||
try {
|
||||
const payload = (await response.json()) as { id?: unknown };
|
||||
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
|
||||
} catch {
|
||||
providerMessageId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, providerMessageId };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
|
||||
@@ -123,13 +123,13 @@ export async function sendPushNotification(
|
||||
url: string,
|
||||
title: string,
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, title, message);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true };
|
||||
return { success: true, providerMessageId: result.providerMessageId };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
|
||||
@@ -345,6 +345,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 2,
|
||||
loosePillsAdded: 5,
|
||||
quantityAdded: 7,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -376,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 1,
|
||||
usedPrescription: false,
|
||||
});
|
||||
});
|
||||
@@ -401,8 +403,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Real /doses/taken routes", () => {
|
||||
it("should mark a dose using real route", async () => {
|
||||
const medicationId = await createMedication(testClient, userId, "Dose Route Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000000`;
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1120,8 +1121,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Real /doses/taken routes - edge cases", () => {
|
||||
it("should return already marked message for duplicate dose", async () => {
|
||||
const medicationId = await createMedication(testClient, userId, "Duplicate Dose Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000000`;
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
// Mark first time
|
||||
await app.inject({
|
||||
@@ -1142,8 +1142,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should handle doses with person name in doseId", async () => {
|
||||
const medicationId = await createMedication(testClient, userId, "Taken By Med", ["Daniel"]);
|
||||
const doseId = `${medicationId}-0-1735344000000-Daniel`;
|
||||
const doseId = "1-0-1735344000000-Daniel";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1355,8 +1354,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should handle dose marking and get taken doses", async () => {
|
||||
const medicationId = await createMedication(testClient, userId, "Coverage Dose Med", []);
|
||||
const doseId = `${medicationId}-0-1735344000099`;
|
||||
const doseId = "99-0-1735344000099";
|
||||
|
||||
// Mark the dose
|
||||
const markResponse = await app.inject({
|
||||
@@ -2447,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3052,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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 () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3245,6 +3359,196 @@ 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 () => {
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
@@ -3276,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3298,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
expect(refillData.refill.packsAdded).toBe(1);
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: 180,
|
||||
expectedQuantityAdded: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedAmountPerPackage: 180,
|
||||
});
|
||||
expect(refillData.refill.loosePillsAdded).toBe(180);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(180);
|
||||
expect(refillData.newStock.totalPills).toBe(360);
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
@@ -3311,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
|
||||
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([
|
||||
{
|
||||
name: "liquid_container",
|
||||
@@ -3327,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 180,
|
||||
expectedPacksAdded: 1,
|
||||
expectedLooseAdded: 180,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 360,
|
||||
expectedAmountPerPackage: 180,
|
||||
},
|
||||
{
|
||||
name: "tube",
|
||||
@@ -3342,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
|
||||
prescriptionLowRefillThreshold: 1,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
|
||||
expectedVisibleStockBeforeRefill: 80,
|
||||
expectedPacksAdded: 2,
|
||||
expectedLooseAdded: 80,
|
||||
expectedRemainingRefills: 1,
|
||||
expectedTotalPills: 160,
|
||||
expectedAmountPerPackage: 40,
|
||||
},
|
||||
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
|
||||
payload,
|
||||
refillPayload,
|
||||
expectedVisibleStockBeforeRefill,
|
||||
expectedPacksAdded,
|
||||
expectedLooseAdded,
|
||||
expectedRemainingRefills,
|
||||
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({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
@@ -3371,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
|
||||
expectedQuantityAdded: expectedLooseAdded,
|
||||
expectedPacksAdded,
|
||||
expectedAmountPerPackage,
|
||||
});
|
||||
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
|
||||
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
|
||||
expect(refillData.prescription.used).toBe(true);
|
||||
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
|
||||
@@ -3386,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(historyResponse.json()[0]).toMatchObject({
|
||||
packsAdded: expectedPacksAdded,
|
||||
loosePillsAdded: expectedLooseAdded,
|
||||
quantityAdded: expectedLooseAdded,
|
||||
usedPrescription: true,
|
||||
});
|
||||
});
|
||||
@@ -3407,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
expect(refillResponse.statusCode).toBe(200);
|
||||
const refillData = refillResponse.json();
|
||||
expect(refillData.refill.packsAdded).toBe(1);
|
||||
await expectRefillInvariants({
|
||||
medId,
|
||||
refillData,
|
||||
visibleStockBeforeRefill: 80,
|
||||
expectedQuantityAdded: 40,
|
||||
expectedPacksAdded: 1,
|
||||
expectedAmountPerPackage: 40,
|
||||
});
|
||||
expect(refillData.refill.loosePillsAdded).toBe(40);
|
||||
expect(refillData.refill.totalPillsAdded).toBe(40);
|
||||
expect(refillData.newStock.totalPills).toBe(120);
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
@@ -47,6 +46,6 @@ Scope and behavior:
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||
| `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_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} />
|
||||
|
||||
<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="/medications" element={<MedicationsPage />} />
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale, withFormattingTimezone } from "../utils";
|
||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
@@ -1092,22 +1092,16 @@ export function MedDetailModal({
|
||||
{refillHistory.map((entry) => (
|
||||
<div key={entry.id} className="refill-history-item">
|
||||
<span className="refill-date">
|
||||
{new Date(entry.refillDate).toLocaleDateString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
)}
|
||||
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
,{" "}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="refill-amount">
|
||||
{(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Medication } from "../types";
|
||||
import {
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getStockDisplayCapacity,
|
||||
isAmountBasedPackageType,
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
@@ -30,7 +31,13 @@ type ReportData = Record<
|
||||
dosesSkipped: number;
|
||||
firstDoseAt: string | null;
|
||||
lastDoseAt: string | null;
|
||||
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
|
||||
refills: {
|
||||
packsAdded: number;
|
||||
loosePillsAdded?: number;
|
||||
quantityAdded: number;
|
||||
usedPrescription: boolean;
|
||||
refillDate: string;
|
||||
}[];
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -377,7 +384,7 @@ function generateTextReport(
|
||||
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
|
||||
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
|
||||
} else {
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
|
||||
lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med))));
|
||||
}
|
||||
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -435,7 +442,7 @@ function generateTextReport(
|
||||
if (data.refills.length > 0) {
|
||||
lines.push(h3(t("report.docRefillHistory")));
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
|
||||
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
|
||||
lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`);
|
||||
}
|
||||
@@ -575,7 +582,7 @@ function buildPrintHtml(
|
||||
if (med.looseTablets > 0)
|
||||
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
|
||||
} else {
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
|
||||
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
|
||||
}
|
||||
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
|
||||
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
|
||||
@@ -641,7 +648,7 @@ function buildPrintHtml(
|
||||
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
|
||||
s += `<ul>`;
|
||||
for (const r of data.refills) {
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
|
||||
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
|
||||
s += `<li>${entry}</li>`;
|
||||
}
|
||||
|
||||
@@ -235,10 +235,6 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
async function markDoseTaken(doseId: string) {
|
||||
if (dismissedDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||
@@ -466,7 +462,7 @@ export function SharedSchedule() {
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
disabled={options.isEmpty}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
@@ -476,7 +472,7 @@ export function SharedSchedule() {
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<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>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -257,8 +257,10 @@ export function MedicationsPage() {
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// View mode: grid (default) or form (edit/new)
|
||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(
|
||||
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||
@@ -269,9 +271,23 @@ export function MedicationsPage() {
|
||||
useEffect(() => {
|
||||
showEditModalRef.current = showEditModal;
|
||||
}, [showEditModal]);
|
||||
const processedEditMedIdRef = useRef<string | null>(null);
|
||||
const processedMedicationLinkRef = useRef<string | null>(null);
|
||||
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
|
||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||
useEffect(() => {
|
||||
@@ -819,12 +835,13 @@ export function MedicationsPage() {
|
||||
[t]
|
||||
);
|
||||
|
||||
const clearEditMedIdParam = useCallback(() => {
|
||||
const clearMedicationLinkParams = useCallback(() => {
|
||||
setSearchParams(
|
||||
(prevParams) => {
|
||||
if (!prevParams.has("editMedId")) return prevParams;
|
||||
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
||||
const nextParams = new URLSearchParams(prevParams);
|
||||
nextParams.delete("editMedId");
|
||||
nextParams.delete("viewMedId");
|
||||
return nextParams;
|
||||
},
|
||||
{ replace: true }
|
||||
@@ -848,7 +865,7 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
clearEditMedIdParam();
|
||||
clearMedicationLinkParams();
|
||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||
closeConfirmedRef.current = true;
|
||||
window.history.back();
|
||||
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
|
||||
if (shouldCloseMobileModal) {
|
||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||
closeConfirmedRef.current = true;
|
||||
clearEditMedIdParam();
|
||||
clearMedicationLinkParams();
|
||||
setShowEditModal(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
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
|
||||
if (showObsoleteConfirm) {
|
||||
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (currentEditMedId) {
|
||||
if (currentMedicationLinkId && currentLinkMode) {
|
||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
clearEditMedIdParam();
|
||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||
clearMedicationLinkParams();
|
||||
}
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (currentEditMedId) {
|
||||
if (currentMedicationLinkId && currentLinkMode) {
|
||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||
}
|
||||
clearEditMedIdParam();
|
||||
clearMedicationLinkParams();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
|
||||
};
|
||||
window.addEventListener("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
|
||||
useEffect(() => {
|
||||
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
|
||||
}, [activeMeds, editingId]);
|
||||
|
||||
useEffect(() => {
|
||||
const editMedId = searchParams.get("editMedId");
|
||||
if (!editMedId) {
|
||||
processedEditMedIdRef.current = null;
|
||||
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
||||
if (!linkedMedId || !linkMode) {
|
||||
processedMedicationLinkRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (processedEditMedIdRef.current === editMedId) return;
|
||||
const parsedMedId = Number.parseInt(editMedId, 10);
|
||||
const linkKey = `${linkMode}:${linkedMedId}`;
|
||||
if (processedMedicationLinkRef.current === linkKey) return;
|
||||
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
||||
if (Number.isNaN(parsedMedId)) return;
|
||||
const medicationToEdit =
|
||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||
if (!medicationToEdit) return;
|
||||
|
||||
processedEditMedIdRef.current = editMedId;
|
||||
processedMedicationLinkRef.current = linkKey;
|
||||
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(false);
|
||||
setReadOnlyView(linkMode === "view");
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("editMedId");
|
||||
nextParams.delete("viewMedId");
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
|
||||
const selectedMedication = useMemo(() => {
|
||||
if (!editingId) return null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 App from "../App";
|
||||
|
||||
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
|
||||
});
|
||||
|
||||
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>,
|
||||
PlannerPage: () => <div>planner-page</div>,
|
||||
SchedulePage: () => <div>schedule-page</div>,
|
||||
@@ -265,6 +273,19 @@ describe("App", () => {
|
||||
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", () => {
|
||||
authMock = {
|
||||
user: null,
|
||||
|
||||
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
|
||||
@@ -190,6 +190,7 @@ describe("ReportModal", () => {
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 20,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
const startEdit = vi.fn();
|
||||
const resetForm = vi.fn();
|
||||
|
||||
@@ -2,6 +2,24 @@ import { existsSync, readFileSync } from "fs";
|
||||
import { defineConfig } from "vite";
|
||||
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
|
||||
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.
|
||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||
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({
|
||||
plugins: [react()],
|
||||
@@ -19,6 +50,8 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts,
|
||||
hmr,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: backendTarget,
|
||||
|
||||
Reference in New Issue
Block a user