fix: restore schedule interaction correctness
* fix: restore schedule interaction correctness * fix: use scheduled stock timing for historical doses
This commit is contained in:
@@ -2,9 +2,10 @@ import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, shareTokens } from "../db/schema.js";
|
||||
import { doseTracking, medications, shareTokens, 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,
|
||||
@@ -12,7 +13,12 @@ import {
|
||||
tokenParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js";
|
||||
import {
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseTakenByJson,
|
||||
personTakesMedication,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Validation Schemas
|
||||
@@ -155,12 +161,66 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
||||
}
|
||||
|
||||
if (!parsedDose.personSuffix) {
|
||||
return true;
|
||||
return intake.takenBy === null;
|
||||
}
|
||||
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
}): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(options.doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
|
||||
const scheduledOccurrenceMs = intake
|
||||
? (() => {
|
||||
const doseDate = new Date(parsedDose.timestampMs);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
return new Date(
|
||||
doseDate.getFullYear(),
|
||||
doseDate.getMonth(),
|
||||
doseDate.getDate(),
|
||||
intakeStart.getHours(),
|
||||
intakeStart.getMinutes(),
|
||||
intakeStart.getSeconds(),
|
||||
intakeStart.getMilliseconds()
|
||||
).getTime();
|
||||
})()
|
||||
: parsedDose.timestampMs;
|
||||
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
|
||||
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
|
||||
return (
|
||||
computeMedicationCurrentStock({
|
||||
medication,
|
||||
doses,
|
||||
stockCalculationMode: options.stockCalculationMode,
|
||||
nowMs: stockBeforeDoseMs,
|
||||
}) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dose Tracking Routes
|
||||
// =============================================================================
|
||||
@@ -235,6 +295,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
409: genericErrorSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
@@ -261,6 +322,16 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
const outOfStock = await isDoseOutOfStock({
|
||||
userId,
|
||||
doseId,
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
});
|
||||
if (outOfStock) {
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
|
||||
// Insert new record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
@@ -513,6 +584,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
409: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
@@ -554,11 +626,27 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
// Insert new record - marked by the takenBy person
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
const outOfStock = await isDoseOutOfStock({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
});
|
||||
if (outOfStock) {
|
||||
request.log.info(
|
||||
`[ShareDose] Rejected out-of-stock mark request (owner=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId})`
|
||||
);
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
|
||||
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
|
||||
const parsedShareDose = parseDoseId(doseId);
|
||||
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
markedBy: share.takenBy, // e.g. "Daniel"
|
||||
markedBy,
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
parseTakenByJson,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
type DoseRow = typeof doseTracking.$inferSelect;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getDoseTakenAtMs(dose: DoseRow): number {
|
||||
const rawTakenAt = Number(dose.takenAt);
|
||||
if (Number.isFinite(rawTakenAt)) {
|
||||
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
|
||||
}
|
||||
|
||||
return new Date(dose.takenAt).getTime();
|
||||
}
|
||||
|
||||
export function computeMedicationCurrentStock(options: {
|
||||
medication: MedicationRow;
|
||||
doses: DoseRow[];
|
||||
stockCalculationMode: "automatic" | "manual";
|
||||
nowMs?: number;
|
||||
}): number {
|
||||
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{
|
||||
usageJson: medication.usageJson,
|
||||
everyJson: medication.everyJson,
|
||||
startJson: medication.startJson,
|
||||
},
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
|
||||
const baseStock = isAmountBasedPackageType(medication.packageType)
|
||||
? medication.looseTablets + (medication.stockAdjustment ?? 0)
|
||||
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
|
||||
medication.looseTablets +
|
||||
(medication.stockAdjustment ?? 0);
|
||||
|
||||
const relevantDoses = doses.filter((dose) => !dose.dismissed);
|
||||
const stockCorrectionCutoff = medication.lastStockCorrectionAt
|
||||
? new Date(medication.lastStockCorrectionAt).getTime()
|
||||
: 0;
|
||||
let consumed = 0;
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
|
||||
|
||||
intakes.forEach((intake, intakeIndex) => {
|
||||
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
||||
if (Number.isNaN(intakeStart)) return;
|
||||
|
||||
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = intakeStart;
|
||||
}
|
||||
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intake.takenBy) {
|
||||
peopleForThisIntake = [intake.takenBy];
|
||||
} else if (medicationTakenBy.length > 0) {
|
||||
peopleForThisIntake = medicationTakenBy;
|
||||
} else {
|
||||
peopleForThisIntake = [null];
|
||||
}
|
||||
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
if (effectiveStart <= nowMs) {
|
||||
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
|
||||
consumed += occurrences * usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doseDateOnlyMs > earlyCutoff) {
|
||||
consumed += usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
intakes.forEach((intake, intakeIndex) => {
|
||||
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
const intakeStartDateOnly = new Date(
|
||||
intakeStart.getFullYear(),
|
||||
intakeStart.getMonth(),
|
||||
intakeStart.getDate()
|
||||
).getTime();
|
||||
if (Number.isNaN(intakeStartDateOnly)) return;
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 10);
|
||||
const doseDateOnlyMs = Number.parseInt(match[3], 10);
|
||||
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const takenAtMs = getDoseTakenAtMs(dose);
|
||||
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
|
||||
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||
consumed += usage;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(baseStock - consumed));
|
||||
}
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type IntakeReminderState,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
parseTakenByJson,
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
|
||||
|
||||
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
|
||||
@@ -133,6 +135,10 @@ async function autoMarkDueIntakesAsTaken(
|
||||
)
|
||||
);
|
||||
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||
const trackedDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
let inserted = 0;
|
||||
|
||||
@@ -152,6 +158,15 @@ async function autoMarkDueIntakesAsTaken(
|
||||
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = med.name || med.genericName || "";
|
||||
let remainingStock = computeMedicationCurrentStock({
|
||||
medication: med,
|
||||
doses: trackedDoses,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
nowMs: now.getTime(),
|
||||
});
|
||||
if (remainingStock <= 0) {
|
||||
continue;
|
||||
}
|
||||
const todaysIntakes = getTodaysIntakes(
|
||||
medDisplayName,
|
||||
intakes,
|
||||
@@ -182,6 +197,14 @@ async function autoMarkDueIntakesAsTaken(
|
||||
continue;
|
||||
}
|
||||
|
||||
const intakeDefinition = intakes[intake.blisterIndex];
|
||||
const usage = intakeDefinition
|
||||
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
|
||||
: 0;
|
||||
if (remainingStock <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
@@ -192,6 +215,16 @@ async function autoMarkDueIntakesAsTaken(
|
||||
});
|
||||
|
||||
existingDoseIds.add(doseId);
|
||||
trackedDoses.push({
|
||||
id: 0,
|
||||
userId: settings.userId,
|
||||
doseId,
|
||||
takenAt: intake.intakeTime,
|
||||
markedBy: null,
|
||||
takenSource: "automatic",
|
||||
dismissed: false,
|
||||
});
|
||||
remainingStock = Math.max(0, remainingStock - usage);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,47 @@ async function createUser(username: string) {
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function insertMedication(options: {
|
||||
id: number;
|
||||
userId: number;
|
||||
takenBy?: string[];
|
||||
packCount?: number;
|
||||
looseTablets?: number;
|
||||
start?: string;
|
||||
}) {
|
||||
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
JSON.stringify(options.takenBy ?? []),
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
intakeStart,
|
||||
"[]",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
|
||||
args: [userId, stockCalculationMode],
|
||||
});
|
||||
}
|
||||
|
||||
async function _insertShareToken(userId: number, token: string, takenBy: string) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
||||
args: [userId, token, takenBy],
|
||||
});
|
||||
}
|
||||
|
||||
function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
@@ -203,6 +244,43 @@ describe("Dose Tracking API", () => {
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||
});
|
||||
|
||||
it("rejects taking a dose when the medication is out of stock", async () => {
|
||||
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId: "5-0-1735344000000" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
});
|
||||
|
||||
it("allows taking a historical dose when stock existed at that occurrence", async () => {
|
||||
await insertMedication({
|
||||
id: 6,
|
||||
userId,
|
||||
packCount: 1,
|
||||
looseTablets: 0,
|
||||
start: "2025-01-01T08:00:00.000Z",
|
||||
});
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const historicalDoseId = "6-0-1736064000000";
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/taken",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId: historicalDoseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /doses/taken", () => {
|
||||
|
||||
@@ -67,6 +67,13 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
@@ -89,6 +96,14 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
@@ -135,4 +150,109 @@ describe("checkAndSendIntakeRemindersForUser", () => {
|
||||
});
|
||||
expect(logger.info).toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||
});
|
||||
|
||||
it("does not auto-mark due intakes when current stock is empty", async () => {
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
const insertMock = vi.mocked(mockedDb.insert);
|
||||
|
||||
selectMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ username: "auto-user" }],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: async () => [
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: false,
|
||||
intakesJson: JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T08:00:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
from: () => ({
|
||||
where: async () => [],
|
||||
}),
|
||||
}) as never
|
||||
);
|
||||
|
||||
insertMock.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
insertedRows.push(row);
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "automatic",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: false,
|
||||
shoutrrrUrl: null,
|
||||
shoutrrrIntakeReminders: false,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(logger.info).not.toHaveBeenCalledWith("[IntakeReminder] Auto-marked 1 due intake dose(s) as taken");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,6 +255,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
@@ -308,6 +311,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-10T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
@@ -346,6 +352,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Test Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [
|
||||
{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" },
|
||||
{ usage: 0.5, every: 1, start: "2025-01-05T20:00:00.000Z" },
|
||||
@@ -407,6 +416,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Weekly Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 7, start: "2025-10-17T08:00:00" }],
|
||||
},
|
||||
});
|
||||
@@ -544,6 +556,9 @@ describe("Integration Tests", () => {
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Interval Med",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-10-17T08:00:00" }],
|
||||
},
|
||||
});
|
||||
@@ -598,6 +613,9 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Aspirin",
|
||||
takenBy: ["Daniel"],
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,14 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
||||
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||
import {
|
||||
type Coverage,
|
||||
type FormState,
|
||||
getMedDisplayName,
|
||||
type Medication,
|
||||
type ScheduleEvent,
|
||||
type StockThresholds,
|
||||
} from "../types";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||
@@ -70,15 +77,11 @@ export interface AppContextValue {
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearingMissed: boolean;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
dismissMissedDoses: (doseIds: string[]) => Promise<void>;
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
@@ -393,6 +396,25 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
|
||||
|
||||
const outOfStockMedicationIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
activeMeds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
|
||||
),
|
||||
[activeMeds, coverageByMed]
|
||||
);
|
||||
|
||||
const effectiveTakenDoses = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
Array.from(doses.takenDoses).filter((doseId) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
return Number.isNaN(medId) || !outOfStockMedicationIds.has(medId);
|
||||
})
|
||||
),
|
||||
[doses.takenDoses, outOfStockMedicationIds]
|
||||
);
|
||||
|
||||
// Centralized stock thresholds for consistent status display across all components
|
||||
const stockThresholds: StockThresholds = useMemo(
|
||||
() => ({
|
||||
@@ -516,8 +538,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, effectiveTakenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, effectiveTakenDoses, doses.dismissedDoses]
|
||||
);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
@@ -777,55 +799,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}, [settingsHook.settings, settingsHook.savedSettings]);
|
||||
|
||||
// New dismissMissedDoses that uses medication-level dismissedUntil dates
|
||||
// This is robust against timestamp changes from schedule updates or timezone fixes
|
||||
const [clearingMissedState, setClearingMissedState] = useState(false);
|
||||
|
||||
const dismissMissedDoses = useCallback(
|
||||
async (doseIds: string[]) => {
|
||||
if (doseIds.length === 0) return;
|
||||
|
||||
// Extract unique medication IDs from dose IDs (format: medId-blisterIdx-timestamp[-person])
|
||||
const medIds = new Set<number>();
|
||||
for (const doseId of doseIds) {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 1) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
if (!Number.isNaN(medId)) {
|
||||
medIds.add(medId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (medIds.size === 0) return;
|
||||
|
||||
// Get today's date in YYYY-MM-DD format
|
||||
const today = new Date();
|
||||
const until = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
setClearingMissedState(true);
|
||||
try {
|
||||
const res = await fetch("/api/medications/dismiss-until", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ medicationIds: Array.from(medIds), until }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Reload medications to get updated dismissedUntil values
|
||||
await medications.loadMeds();
|
||||
doses.setShowClearMissedConfirm(false);
|
||||
}
|
||||
} catch {
|
||||
// Error - dialog stays open
|
||||
} finally {
|
||||
setClearingMissedState(false);
|
||||
}
|
||||
},
|
||||
[medications, doses]
|
||||
);
|
||||
|
||||
// Build context value
|
||||
const value: AppContextValue = useMemo(
|
||||
() => ({
|
||||
@@ -853,15 +826,11 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
takenDoses: doses.takenDoses,
|
||||
setTakenDoses: doses.setTakenDoses,
|
||||
dismissedDoses: doses.dismissedDoses,
|
||||
clearingMissed: clearingMissedState,
|
||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||
getDoseId: doses.getDoseId,
|
||||
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||
countTakenDoses: doses.countTakenDoses,
|
||||
markDoseTaken: doses.markDoseTaken,
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
dismissMissedDoses,
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||
@@ -1020,8 +989,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
handleImportFileSelect,
|
||||
handleImportConfirm,
|
||||
settingsChanged,
|
||||
clearingMissedState,
|
||||
dismissMissedDoses,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
@@ -10,8 +11,6 @@ export interface UseDosesReturn {
|
||||
takenDoseTimestamps: Map<string, number>;
|
||||
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||
dismissedDoses: Set<string>;
|
||||
showClearMissedConfirm: boolean;
|
||||
setShowClearMissedConfirm: (show: boolean) => void;
|
||||
clearDosesState: () => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
@@ -22,11 +21,11 @@ export interface UseDosesReturn {
|
||||
}
|
||||
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const { t } = useTranslation();
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
|
||||
// Track in-flight mutations to prevent polling from overwriting optimistic updates
|
||||
const mutationInFlightRef = useRef(0);
|
||||
@@ -36,7 +35,6 @@ export function useDoses(): UseDosesReturn {
|
||||
setTakenDoseTimestamps(new Map());
|
||||
setTakenDoseSources(new Map());
|
||||
setDismissedDoses(new Set());
|
||||
setShowClearMissedConfirm(false);
|
||||
mutationInFlightRef.current = 0;
|
||||
}, []);
|
||||
|
||||
@@ -118,6 +116,15 @@ export function useDoses(): UseDosesReturn {
|
||||
[takenDoses, getDoseId]
|
||||
);
|
||||
|
||||
const getErrorCode = useCallback(async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const data = (await response.json()) as { code?: string };
|
||||
return typeof data.code === "string" ? data.code : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markDoseTaken = useCallback(
|
||||
async (doseId: string) => {
|
||||
// Optimistic update
|
||||
@@ -140,12 +147,18 @@ export function useDoses(): UseDosesReturn {
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch("/api/doses/taken", {
|
||||
const response = await fetch("/api/doses/taken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
|
||||
alert(t("common.outOfStockTakeBlocked"));
|
||||
}
|
||||
throw new Error("Failed to mark dose as taken");
|
||||
}
|
||||
} catch {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
@@ -169,7 +182,7 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[loadTakenDoses]
|
||||
[getErrorCode, loadTakenDoses, t]
|
||||
);
|
||||
|
||||
const undoDoseTaken = useCallback(
|
||||
@@ -220,8 +233,6 @@ export function useDoses(): UseDosesReturn {
|
||||
takenDoseTimestamps,
|
||||
takenDoseSources,
|
||||
dismissedDoses,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
clearDosesState,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
type Coverage,
|
||||
@@ -76,19 +75,28 @@ export function DashboardPage() {
|
||||
getDayStockStatus,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
showClearMissedConfirm,
|
||||
setShowClearMissedConfirm,
|
||||
clearingMissed,
|
||||
dismissMissedDoses,
|
||||
openMedDetail,
|
||||
openUserFilter,
|
||||
openShareDialog,
|
||||
openScheduleLightbox,
|
||||
stockThresholds,
|
||||
loadMeds,
|
||||
loadSettings,
|
||||
} = useAppContext();
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
|
||||
const outOfStockMedicationIds = new Set(
|
||||
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
|
||||
);
|
||||
|
||||
const isDoseTakenForDisplay = (doseId: string) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
|
||||
return false;
|
||||
}
|
||||
return takenDoses.has(doseId);
|
||||
};
|
||||
|
||||
// Get structured reminder data
|
||||
const reminderData = getReminderStatusData(
|
||||
@@ -141,6 +149,67 @@ export function DashboardPage() {
|
||||
|
||||
const prescriptionEmptyCount = prescriptionLowMeds.filter((med) => med.remainingRefills <= 0).length;
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/medications/dismiss-until", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
await loadMeds();
|
||||
setShowClearMissedConfirm(false);
|
||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
@@ -895,8 +964,8 @@ export function DashboardPage() {
|
||||
);
|
||||
|
||||
// Really taken = all doses marked as taken by human (for green "All taken")
|
||||
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
|
||||
|
||||
// Count missed doses that are NOT dismissed (for warning icon)
|
||||
const missedNotDismissedCount = day.meds.reduce((count, item) => {
|
||||
@@ -908,7 +977,9 @@ export function DashboardPage() {
|
||||
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
|
||||
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
|
||||
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
|
||||
return (
|
||||
doseCount + ids.filter((id) => !isDoseTakenForDisplay(id) && !dismissedDoses.has(id)).length
|
||||
);
|
||||
}, 0)
|
||||
);
|
||||
}, 0);
|
||||
@@ -963,13 +1034,8 @@ export function DashboardPage() {
|
||||
? getStockStatus(medCov.daysLeft, medCov.medsLeft, stockThresholds, med?.packageType)
|
||||
: null;
|
||||
const status = getVisibleStockStatus(med, rawStatus);
|
||||
const itemDoseIds = expandDoseIds(item.doses);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||
>
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1013,8 +1079,11 @@ export function DashboardPage() {
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -1035,7 +1104,7 @@ export function DashboardPage() {
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
@@ -1070,13 +1139,17 @@ export function DashboardPage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("dose.markAsTaken")}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1154,11 +1227,7 @@ export function DashboardPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="clear-missed-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowClearMissedConfirm(true);
|
||||
}}
|
||||
title={t("dashboard.schedules.clearMissed")}
|
||||
onClick={() => setShowClearMissedConfirm(true)}
|
||||
>
|
||||
{t("dashboard.schedules.clearMissed")}
|
||||
</button>
|
||||
@@ -1166,13 +1235,29 @@ export function DashboardPage() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{showClearMissedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("dashboard.schedules.clearMissedConfirmTitle")}
|
||||
message={t("dashboard.schedules.clearMissedConfirmMessage", {
|
||||
count: missedPastDoseIds.length,
|
||||
})}
|
||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||
onCancel={() => {
|
||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
||||
}}
|
||||
isLoading={clearingMissed}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{/* Today - always visible */}
|
||||
{todayDay &&
|
||||
(() => {
|
||||
const day = todayDay;
|
||||
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
|
||||
|
||||
const dayStockStatuses = day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
@@ -1244,13 +1329,8 @@ export function DashboardPage() {
|
||||
)
|
||||
: null;
|
||||
const visibleStatus = getVisibleStockStatus(med, status);
|
||||
const itemDoseIds = expandDoseIds(item.doses);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||
>
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1297,7 +1377,7 @@ export function DashboardPage() {
|
||||
const isOverdue = dose.when < Date.now();
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) =>
|
||||
takenDoses.has(getDoseId(dose.id, person))
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -1324,7 +1404,7 @@ export function DashboardPage() {
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
@@ -1359,13 +1439,17 @@ export function DashboardPage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("dose.markAsTaken")}
|
||||
title={
|
||||
isEmpty
|
||||
? t("common.outOfStockTakeBlocked")
|
||||
: t("dose.markAsTaken")
|
||||
}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1393,7 +1477,7 @@ export function DashboardPage() {
|
||||
)
|
||||
)
|
||||
);
|
||||
const takenFutureDoses = totalFutureDoses.filter((id) => takenDoses.has(id)).length;
|
||||
const takenFutureDoses = totalFutureDoses.filter((id) => isDoseTakenForDisplay(id)).length;
|
||||
return (
|
||||
<div className="future-days-header">
|
||||
<div
|
||||
@@ -1426,8 +1510,8 @@ export function DashboardPage() {
|
||||
showFutureDays &&
|
||||
futureDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
|
||||
|
||||
const dayStockStatuses = day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
@@ -1498,13 +1582,8 @@ export function DashboardPage() {
|
||||
)
|
||||
: null;
|
||||
const visibleStatus = getVisibleStockStatus(med, status);
|
||||
const itemDoseIds = expandDoseIds(item.doses);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div
|
||||
key={`${day.dateStr}-${item.medName}`}
|
||||
className={`time-row ${allTaken ? "taken" : ""}`}
|
||||
>
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<div
|
||||
@@ -1550,7 +1629,7 @@ export function DashboardPage() {
|
||||
{item.doses.map((dose) => {
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) =>
|
||||
takenDoses.has(getDoseId(dose.id, person))
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item future ${allTaken ? "all-taken" : ""}`}>
|
||||
@@ -1574,7 +1653,7 @@ export function DashboardPage() {
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
@@ -1609,13 +1688,13 @@ export function DashboardPage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
className={`dose-btn take out-of-stock`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
title={t("dose.markAsTaken")}
|
||||
title={t("common.outOfStockTakeBlocked")}
|
||||
disabled={true}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span aria-hidden="true">⊘</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1637,19 +1716,6 @@ export function DashboardPage() {
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Clear Missed Doses Confirmation Modal */}
|
||||
{showClearMissedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("dashboard.schedules.clearMissedConfirmTitle")}
|
||||
message={t("dashboard.schedules.clearMissedConfirmMessage", { count: missedPastDoseIds.length })}
|
||||
confirmLabel={clearingMissed ? t("common.loading") : t("dashboard.schedules.clearMissedConfirm")}
|
||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||
onConfirm={() => dismissMissedDoses(missedPastDoseIds)}
|
||||
onCancel={() => setShowClearMissedConfirm(false)}
|
||||
isLoading={clearingMissed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||
import { Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { Coverage } from "../types";
|
||||
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { formatNumber } from "../utils/formatters";
|
||||
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
|
||||
import { isDoseDismissed } from "../utils/schedule";
|
||||
|
||||
// Helper for user-specific localStorage keys
|
||||
function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -90,13 +91,89 @@ export function SchedulePage() {
|
||||
toggleDayCollapse,
|
||||
openUserFilter,
|
||||
missedPastDoseIds,
|
||||
loadMeds,
|
||||
} = useAppContext();
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
|
||||
const outOfStockMedicationIds = new Set(
|
||||
meds.filter((med) => (coverageByMed[getMedDisplayName(med)]?.medsLeft ?? 1) <= 0).map((med) => med.id)
|
||||
);
|
||||
|
||||
const isDoseTakenForDisplay = (doseId: string) => {
|
||||
const medId = Number.parseInt(doseId.split("-")[0] ?? "", 10);
|
||||
if (!Number.isNaN(medId) && outOfStockMedicationIds.has(medId)) {
|
||||
return false;
|
||||
}
|
||||
return takenDoses.has(doseId);
|
||||
};
|
||||
|
||||
const shouldHideNoScheduleStatusForTube = (
|
||||
med: (typeof meds)[number] | undefined,
|
||||
status: { className: string; label: string } | null
|
||||
) => isTubePackageType(med?.packageType) && status?.label === "status.noSchedule";
|
||||
|
||||
const getClearMissedPayload = () => {
|
||||
const medicationIds = new Set<number>();
|
||||
let latestMissedDate: string | null = null;
|
||||
|
||||
for (const day of pastDays) {
|
||||
for (const item of day.meds) {
|
||||
const med = meds.find((candidate) => getMedDisplayName(candidate) === item.medName);
|
||||
if (!med) continue;
|
||||
|
||||
const dismissedUntilDate = med.dismissedUntil ?? undefined;
|
||||
const hasMissedDose = item.doses.some((dose) => {
|
||||
if (isDoseDismissed(dose.id, dismissedUntilDate)) return false;
|
||||
const takenByArray = Array.isArray(dose.takenBy) ? dose.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((person) => `${dose.id}-${person}`) : [dose.id];
|
||||
return ids.some((doseId) => !isDoseTakenForDisplay(doseId) && !dismissedDoses.has(doseId));
|
||||
});
|
||||
|
||||
if (!hasMissedDose) continue;
|
||||
|
||||
medicationIds.add(med.id);
|
||||
const dayDate = day.date.toISOString().slice(0, 10);
|
||||
if (!latestMissedDate || dayDate > latestMissedDate) {
|
||||
latestMissedDate = dayDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
medicationIds: [...medicationIds],
|
||||
until: latestMissedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const clearMissedDoses = async (missedCount: number) => {
|
||||
const payload = getClearMissedPayload();
|
||||
if (payload.medicationIds.length === 0 || !payload.until) {
|
||||
setShowClearMissedConfirm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/medications/dismiss-until", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
await loadMeds();
|
||||
setShowClearMissedConfirm(false);
|
||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
} finally {
|
||||
setClearingMissed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
|
||||
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
|
||||
? t("form.packageAmountUnitMl")
|
||||
@@ -205,8 +282,8 @@ export function SchedulePage() {
|
||||
);
|
||||
|
||||
// Really taken = all doses marked as taken by human (for green "All taken")
|
||||
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => isDoseTakenForDisplay(id));
|
||||
const takenCount = allDoseIds.filter((id) => isDoseTakenForDisplay(id)).length;
|
||||
|
||||
// Count missed doses that are NOT dismissed (for warning icon)
|
||||
const missedNotDismissedCount = day.meds.reduce((count, item) => {
|
||||
@@ -218,7 +295,7 @@ export function SchedulePage() {
|
||||
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
|
||||
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
|
||||
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
|
||||
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
|
||||
return doseCount + ids.filter((id) => !isDoseTakenForDisplay(id) && !dismissedDoses.has(id)).length;
|
||||
}, 0)
|
||||
);
|
||||
}, 0);
|
||||
@@ -268,10 +345,8 @@ export function SchedulePage() {
|
||||
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = expandDoseIds(item.doses);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
@@ -285,8 +360,11 @@ export function SchedulePage() {
|
||||
{item.doses.map((dose) => {
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const allTaken = people.every((person) =>
|
||||
isDoseTakenForDisplay(getDoseId(dose.id, person))
|
||||
);
|
||||
return (
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<div key={dose.id} className={`dose-item past ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -307,7 +385,7 @@ export function SchedulePage() {
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
return (
|
||||
@@ -341,13 +419,15 @@ export function SchedulePage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,21 +449,10 @@ export function SchedulePage() {
|
||||
(() => {
|
||||
const missedCount = missedPastDoseIds.length;
|
||||
return (
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
|
||||
onClick={() => {
|
||||
const wasCollapsed = !showPastDays;
|
||||
setShowPastDays(!showPastDays);
|
||||
if (wasCollapsed) {
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector(".day-block.today")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
<div className="past-days-header">
|
||||
<div
|
||||
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
|
||||
onClick={() => {
|
||||
const wasCollapsed = !showPastDays;
|
||||
setShowPastDays(!showPastDays);
|
||||
if (wasCollapsed) {
|
||||
@@ -393,27 +462,61 @@ export function SchedulePage() {
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
|
||||
</span>
|
||||
<span className="past-days-count">
|
||||
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
|
||||
</span>
|
||||
{missedCount > 0 && (
|
||||
<span
|
||||
className="past-days-warning"
|
||||
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
|
||||
>
|
||||
⚠️ {missedCount}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
const wasCollapsed = !showPastDays;
|
||||
setShowPastDays(!showPastDays);
|
||||
if (wasCollapsed) {
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector(".day-block.today")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
|
||||
<span className="past-days-label">
|
||||
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
|
||||
</span>
|
||||
<span className="past-days-count">
|
||||
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
|
||||
</span>
|
||||
{missedCount > 0 && (
|
||||
<span
|
||||
className="past-days-warning"
|
||||
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
|
||||
>
|
||||
⚠️ {missedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{missedCount > 0 && (
|
||||
<button type="button" className="clear-missed-btn" onClick={() => setShowClearMissedConfirm(true)}>
|
||||
{t("dashboard.schedules.clearMissed")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{showClearMissedConfirm && (
|
||||
<ConfirmModal
|
||||
title={t("dashboard.schedules.clearMissedConfirmTitle")}
|
||||
message={t("dashboard.schedules.clearMissedConfirmMessage", {
|
||||
count: missedPastDoseIds.length,
|
||||
})}
|
||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||
onCancel={() => {
|
||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
||||
}}
|
||||
isLoading={clearingMissed}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
)}
|
||||
{/* Current and future days */}
|
||||
{futureDays.map((day) => {
|
||||
const today = new Date();
|
||||
@@ -437,10 +540,8 @@ export function SchedulePage() {
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med?.packageType)
|
||||
: null;
|
||||
const visibleStatus = shouldHideNoScheduleStatusForTube(med, status) ? null : status;
|
||||
const itemDoseIds = expandDoseIds(item.doses);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div key={`${day.dateStr}-${item.medName}`} className="time-row">
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
@@ -459,8 +560,9 @@ export function SchedulePage() {
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
const allTaken = people.every((person) => isDoseTakenForDisplay(getDoseId(dose.id, person)));
|
||||
return (
|
||||
<div key={dose.id} className="dose-item">
|
||||
<div key={dose.id} className={`dose-item ${allTaken ? "all-taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
<span className="dose-usage-main">
|
||||
@@ -481,7 +583,7 @@ export function SchedulePage() {
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
@@ -516,13 +618,13 @@ export function SchedulePage() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn take"
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={t("dose.markAsTaken")}
|
||||
title={isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">✓</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,8 +196,6 @@ describe("useAppContext", () => {
|
||||
setTakenDoses: vi.fn(),
|
||||
takenDoseTimestamps: new Map<string, number>(),
|
||||
dismissedDoses: new Set<string>(),
|
||||
showClearMissedConfirm: true,
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearDosesState: vi.fn(),
|
||||
getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)),
|
||||
isDoseTakenAutomatically: vi.fn(() => false),
|
||||
@@ -376,38 +374,6 @@ describe("useAppContext", () => {
|
||||
expect(window.history.back).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismisses missed doses and posts unique medication IDs", async () => {
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses(["11-0-1730000000000", "11-2-1730000100000", "12-0-1730000200000"]);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/dismiss-until",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
|
||||
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string);
|
||||
expect(body.medicationIds).toEqual([11, 12]);
|
||||
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
|
||||
expect(mockUseDoses().setShowClearMissedConfirm).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("does not dismiss missed doses for empty/invalid IDs", async () => {
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses([]);
|
||||
await result.current.dismissMissedDoses(["invalid-dose-id"]);
|
||||
});
|
||||
|
||||
expect(fetch).not.toHaveBeenCalledWith("/api/medications/dismiss-until", expect.anything());
|
||||
});
|
||||
|
||||
it("imports data and triggers reload plus import result state", async () => {
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
@@ -583,15 +549,4 @@ describe("useAppContext", () => {
|
||||
|
||||
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
|
||||
});
|
||||
|
||||
it("keeps clear-missed confirm open when dismiss request fails", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("network"));
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.dismissMissedDoses(["11-0-1730000000000"]);
|
||||
});
|
||||
|
||||
expect(mockUseDoses().setShowClearMissedConfirm).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("useDoses", () => {
|
||||
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
expect(result.current.dismissedDoses.size).toBe(0);
|
||||
expect(result.current.showClearMissedConfirm).toBe(false);
|
||||
});
|
||||
|
||||
it("loads taken doses from API on mount", async () => {
|
||||
@@ -273,14 +272,32 @@ describe("useDoses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("setShowClearMissedConfirm works", () => {
|
||||
it("shows an out-of-stock alert and reverts the optimistic mark", async () => {
|
||||
const alertMock = vi.fn();
|
||||
global.alert = alertMock;
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ code: "OUT_OF_STOCK" }),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
|
||||
const { result } = renderHook(() => useDoses());
|
||||
|
||||
act(() => {
|
||||
result.current.setShowClearMissedConfirm(true);
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.size).toBe(0);
|
||||
});
|
||||
|
||||
expect(result.current.showClearMissedConfirm).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.markDoseTaken("blocked-dose");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.takenDoses.has("blocked-dose")).toBe(false);
|
||||
});
|
||||
expect(alertMock).toHaveBeenCalledWith("common.outOfStockTakeBlocked");
|
||||
});
|
||||
|
||||
it("undoDoseTaken encodes special characters in dose ID", async () => {
|
||||
|
||||
@@ -182,10 +182,7 @@ const createMockAppContext = (overrides = {}) => ({
|
||||
getDayStockStatus: vi.fn(() => "success"),
|
||||
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
|
||||
isDoseTakenAutomatically: vi.fn(() => false),
|
||||
showClearMissedConfirm: false,
|
||||
setShowClearMissedConfirm: vi.fn(),
|
||||
clearingMissed: false,
|
||||
dismissMissedDoses: vi.fn(),
|
||||
loadMeds: vi.fn(),
|
||||
loadSettings: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
@@ -977,26 +974,18 @@ describe("DashboardPage with past days", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows clear missed doses button when there are missed doses", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it("posts the computed dismiss-until payload when clearing missed doses", async () => {
|
||||
const loadMeds = vi.fn();
|
||||
const alertMock = vi.fn();
|
||||
global.alert = alertMock;
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
// Should show clear missed button
|
||||
const clearBtn = document.querySelector(".clear-missed-btn");
|
||||
expect(clearBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens clear missed confirmation modal and confirms action", () => {
|
||||
const dismissMissedDoses = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
meds: mockMeds,
|
||||
coverageByMed: { Aspirin: { medsLeft: 25, daysLeft: 25 } },
|
||||
pastDays: mockPastDays,
|
||||
showPastDays: false,
|
||||
missedPastDoseIds: ["1-0-1-John", "1-0-2-John"],
|
||||
showClearMissedConfirm: true,
|
||||
dismissMissedDoses,
|
||||
missedPastDoseIds: [`${mockPastDays[0].meds[0].doses[0].id}-John`],
|
||||
loadMeds,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -1005,9 +994,26 @@ describe("DashboardPage with past days", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/dashboard\.schedules\.clearMissedConfirmTitle/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissed/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /dashboard\.schedules\.clearMissedConfirm/i }));
|
||||
expect(dismissMissedDoses).toHaveBeenCalledWith(["1-0-1-John", "1-0-2-John"]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/dismiss-until",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const body = JSON.parse(((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]?.[1]?.body as string) ?? "{}");
|
||||
expect(body).toEqual({
|
||||
medicationIds: [1],
|
||||
until: mockPastDays[0].date.toISOString().slice(0, 10),
|
||||
});
|
||||
expect(loadMeds).toHaveBeenCalled();
|
||||
expect(alertMock).toHaveBeenCalledWith(expect.stringContaining("dashboard.schedules.clearMissedSuccess"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1169,25 +1175,6 @@ describe("DashboardPage additional branches", () => {
|
||||
expect(openScheduleLightbox).toHaveBeenCalledWith("/api/images/aspirin.png");
|
||||
});
|
||||
|
||||
it("clicking clear missed button opens confirmation", () => {
|
||||
const setShowClearMissedConfirm = vi.fn();
|
||||
mockContextValue = createMockAppContext({
|
||||
pastDays: mockPastDays,
|
||||
missedPastDoseIds: ["1-0-1-John"],
|
||||
setShowClearMissedConfirm,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const clearBtn = document.querySelector(".clear-missed-btn") as HTMLButtonElement;
|
||||
fireEvent.click(clearBtn);
|
||||
expect(setShowClearMissedConfirm).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("renders and interacts with today day schedule block", () => {
|
||||
const markDoseTaken = vi.fn();
|
||||
const undoDoseTaken = vi.fn();
|
||||
|
||||
@@ -1636,6 +1636,15 @@ describe("computeMissedPastDoseIds", () => {
|
||||
expect(result).toEqual([`1-0-${march13}`, `1-0-${march14}`]);
|
||||
});
|
||||
|
||||
it("matches medication dismissedUntil via display name when the schedule row uses genericName", () => {
|
||||
const march10 = new Date("2024-03-10T00:00:00").getTime();
|
||||
const pastDays = [makePastDay("Acetylsalicylic Acid", [{ id: `1-0-${march10}` }])];
|
||||
const meds = [{ name: "", genericName: "Acetylsalicylic Acid", dismissedUntil: "2024-03-12" }];
|
||||
|
||||
const result = computeMissedPastDoseIds(pastDays, meds, new Set(), new Set());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands takenBy people into separate dose IDs", () => {
|
||||
const march10 = new Date("2024-03-10T00:00:00").getTime();
|
||||
const pastDays = [makePastDay("SharedMed", [{ id: `1-0-${march10}`, takenBy: ["Alice", "Bob"] }])];
|
||||
|
||||
@@ -597,13 +597,13 @@ export function computeMissedPastDoseIds(
|
||||
doses: ReadonlyArray<{ id: string; takenBy: string[] }>;
|
||||
}>;
|
||||
}>,
|
||||
medications: ReadonlyArray<{ name: string; dismissedUntil?: string | null }>,
|
||||
medications: ReadonlyArray<{ name: string; genericName?: string | null; dismissedUntil?: string | null }>,
|
||||
takenDoses: Set<string>,
|
||||
dismissedDoses: Set<string>
|
||||
): string[] {
|
||||
const totalPastDoses = pastDays.flatMap((d) =>
|
||||
d.meds.flatMap((m) => {
|
||||
const med = medications.find((med) => med.name === m.medName);
|
||||
const med = medications.find((medication) => getMedDisplayName(medication as Medication) === m.medName);
|
||||
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
|
||||
|
||||
return m.doses.flatMap((dose) => {
|
||||
|
||||
Reference in New Issue
Block a user