Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 228fd4cd7e | |||
| e346d60f39 | |||
| afb8e5028c | |||
| 9ab077a037 | |||
| 976d7356ec | |||
| 943148fb49 | |||
| 94bd8bd6e8 | |||
| 0cf1c5353e | |||
| 98cf1ce1d2 | |||
| 75c201cab5 | |||
| 74f079d13e | |||
| fd3b770a81 | |||
| 612aa007aa | |||
| 02af93ec55 |
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-558%2F558-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-776%2F776-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1771164000000,
|
"when": 1771164000000,
|
||||||
"tag": "0009_add_medication_start_date",
|
"tag": "0009_add_medication_start_date",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771694832866,
|
||||||
|
"tag": "0010_mean_spot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.12.0",
|
"version": "1.14.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for intake automation auditability (manual vs automatic taken)
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
|
||||||
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -94,6 +95,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: null, // Marked by the user themselves
|
markedBy: null, // Marked by the user themselves
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -227,6 +229,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -270,6 +273,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
userId: share.userId,
|
userId: share.userId,
|
||||||
doseId,
|
doseId,
|
||||||
markedBy: share.takenBy, // e.g. "Daniel"
|
markedBy: share.takenBy, // e.g. "Daniel"
|
||||||
|
takenSource: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const doseHistorySchema = z.object({
|
|||||||
scheduledTime: z.string(), // ISO datetime
|
scheduledTime: z.string(), // ISO datetime
|
||||||
takenAt: z.string(), // ISO datetime
|
takenAt: z.string(), // ISO datetime
|
||||||
markedBy: z.string().nullable().optional(),
|
markedBy: z.string().nullable().optional(),
|
||||||
|
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||||
dismissed: z.boolean().default(false),
|
dismissed: z.boolean().default(false),
|
||||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
});
|
});
|
||||||
@@ -364,6 +365,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
scheduledTime: scheduledTimeIso,
|
scheduledTime: scheduledTimeIso,
|
||||||
takenAt: takenAtIso,
|
takenAt: takenAtIso,
|
||||||
markedBy: dose.markedBy,
|
markedBy: dose.markedBy,
|
||||||
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenByPerson: parsed.person,
|
takenByPerson: parsed.person,
|
||||||
};
|
};
|
||||||
@@ -625,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { doseTracking, medications } from "../db/schema.js";
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -792,26 +792,37 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const [settingsRow] = await db
|
||||||
|
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||||
|
.from(userSettings)
|
||||||
|
.where(eq(userSettings.userId, userId));
|
||||||
|
const stockCalculationMode = settingsRow?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||||
|
|
||||||
// Get all taken doses for this user to calculate actual consumption
|
// Get all taken doses for this user to calculate actual consumption
|
||||||
const takenDoses = await db
|
const takenDoses = await db
|
||||||
.select()
|
.select()
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
// Create a map of medication ID to taken dose count
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
takenDoses.forEach((dose) => {
|
takenDoses.forEach((dose) => {
|
||||||
const parts = dose.doseId.split("-");
|
const parts = dose.doseId.split("-");
|
||||||
if (parts.length >= 3) {
|
if (parts.length < 3) return;
|
||||||
const medId = parseInt(parts[0], 10);
|
const medId = parseInt(parts[0], 10);
|
||||||
const blisterIdx = parseInt(parts[1], 10);
|
if (Number.isNaN(medId)) return;
|
||||||
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
|
|
||||||
if (!takenDosesMap.has(medId)) {
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
takenDosesMap.set(medId, []);
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
}
|
|
||||||
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||||
|
? rawTakenAt < 1_000_000_000_000
|
||||||
|
? rawTakenAt * 1000
|
||||||
|
: rawTakenAt
|
||||||
|
: new Date(dose.takenAt).getTime();
|
||||||
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use current time as the reference point for "available" stock
|
// Use current time as the reference point for "available" stock
|
||||||
@@ -838,66 +849,106 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
? looseTablets + stockAdjustment
|
? looseTablets + stockAdjustment
|
||||||
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption based on ACTUAL taken doses from dose_tracking
|
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
|
||||||
// This ensures Planner shows the same "current stock" as the Dashboard/Modal
|
|
||||||
// Use the same logic as frontend: generate expected doses and check which are marked
|
|
||||||
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
// Build a Set of taken dose IDs for quick lookup
|
|
||||||
const takenDoseIds = new Set(
|
|
||||||
takenDoses
|
|
||||||
.filter((dose) => {
|
|
||||||
const parts = dose.doseId.split("-");
|
|
||||||
return parts.length >= 3 && parseInt(parts[0], 10) === row.id;
|
|
||||||
})
|
|
||||||
.map((dose) => dose.doseId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count consumed pills by generating expected doses and checking if they're taken
|
// Count consumed pills by generating expected doses and checking if they're taken
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
const msPerDay = 86400000;
|
const msPerDay = 86400000;
|
||||||
|
|
||||||
blisters.forEach((blister, blisterIdx) => {
|
if (stockCalculationMode === "automatic") {
|
||||||
const blisterStart = parseLocalDateTime(blister.start);
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
if (Number.isNaN(blisterStart.getTime())) return;
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
const period = Math.max(1, blister.every) * msPerDay;
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
// After a stock correction, start counting from the NEXT scheduled
|
let effectiveStart: number;
|
||||||
// dose, because the user's pill count already reflects all
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
// consumption up to the correction time.
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
let effectiveStart: number;
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
effectiveStart = stockCorrectionCutoff + period;
|
} else {
|
||||||
} else {
|
effectiveStart = blisterStart;
|
||||||
effectiveStart = blisterStart.getTime();
|
}
|
||||||
}
|
|
||||||
if (effectiveStart > now.getTime()) return;
|
|
||||||
|
|
||||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
|
const peopleForThisIntake = intakePerson
|
||||||
|
? [intakePerson]
|
||||||
|
: fallbackPeople.length > 0
|
||||||
|
? fallbackPeople
|
||||||
|
: [null];
|
||||||
|
|
||||||
// Get the people for this intake (from intakes array or medication takenBy)
|
let timeBasedConsumed = 0;
|
||||||
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
|
let lastAutoConsumedDateMs = 0;
|
||||||
const intake = intakes[blisterIdx];
|
|
||||||
const intakePerson = intake?.takenBy;
|
|
||||||
const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null];
|
|
||||||
const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback;
|
|
||||||
|
|
||||||
// Generate expected dose IDs and check if they're taken
|
if (effectiveStart <= now.getTime()) {
|
||||||
for (let i = 0; i < occurrences; i++) {
|
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||||
const doseDate = new Date(effectiveStart + i * period);
|
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||||
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
|
|
||||||
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
|
|
||||||
|
|
||||||
// Check if each person has taken this dose
|
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||||
for (const person of peopleForThisIntake) {
|
lastAutoConsumedDateMs = new Date(
|
||||||
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
|
lastDoseTime.getFullYear(),
|
||||||
if (takenDoseIds.has(doseId)) {
|
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);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedUntilNow += timeBasedConsumed + earlyTakenConsumed;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start);
|
||||||
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStart.getFullYear(),
|
||||||
|
blisterStart.getMonth(),
|
||||||
|
blisterStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
|
|
||||||
|
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
consumedUntilNow += blister.usage;
|
consumedUntilNow += blister.usage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
|
||||||
|
|
||||||
@@ -943,6 +994,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
medicationId: row.id,
|
medicationId: row.id,
|
||||||
medicationName: row.name,
|
medicationName: row.name,
|
||||||
totalPills: currentStock,
|
totalPills: currentStock,
|
||||||
|
currentPills: currentStock,
|
||||||
plannerUsage: usageTotal,
|
plannerUsage: usageTotal,
|
||||||
blisterSize: pillsPerBlister,
|
blisterSize: pillsPerBlister,
|
||||||
blistersNeeded,
|
blistersNeeded,
|
||||||
|
|||||||
@@ -51,17 +51,22 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
doseId: doseTracking.doseId,
|
doseId: doseTracking.doseId,
|
||||||
takenAt: doseTracking.takenAt,
|
takenAt: doseTracking.takenAt,
|
||||||
dismissed: doseTracking.dismissed,
|
dismissed: doseTracking.dismissed,
|
||||||
|
takenSource: doseTracking.takenSource,
|
||||||
})
|
})
|
||||||
.from(doseTracking)
|
.from(doseTracking)
|
||||||
.where(eq(doseTracking.userId, userId));
|
.where(eq(doseTracking.userId, userId));
|
||||||
|
|
||||||
// Group doses by medication ID
|
// Group doses by medication ID
|
||||||
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean }[]>();
|
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
|
||||||
for (const dose of allDoses) {
|
for (const dose of allDoses) {
|
||||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({ takenAt: dose.takenAt, dismissed: dose.dismissed });
|
dosesByMed.get(medId)!.push({
|
||||||
|
takenAt: dose.takenAt,
|
||||||
|
dismissed: dose.dismissed,
|
||||||
|
takenSource: dose.takenSource ?? "manual",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch refill history for requested medications
|
// Fetch refill history for requested medications
|
||||||
@@ -69,6 +74,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesDismissed: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
@@ -79,6 +85,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
for (const medId of medicationIds) {
|
for (const medId of medicationIds) {
|
||||||
const doses = dosesByMed.get(medId) ?? [];
|
const doses = dosesByMed.get(medId) ?? [];
|
||||||
const takenDoses = doses.filter((d) => !d.dismissed);
|
const takenDoses = doses.filter((d) => !d.dismissed);
|
||||||
|
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
|
||||||
const dismissedDoses = doses.filter((d) => d.dismissed);
|
const dismissedDoses = doses.filter((d) => d.dismissed);
|
||||||
|
|
||||||
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
|
||||||
@@ -88,6 +95,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
result[medId] = {
|
result[medId] = {
|
||||||
dosesTaken: takenDoses.length,
|
dosesTaken: takenDoses.length,
|
||||||
|
automaticDosesTaken: automaticTakenDoses.length,
|
||||||
dosesDismissed: dismissedDoses.length,
|
dosesDismissed: dismissedDoses.length,
|
||||||
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).toISOString() : null,
|
||||||
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
lastDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[sortedTaken.length - 1]).toISOString() : null,
|
||||||
|
|||||||
@@ -50,6 +50,113 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
|
|||||||
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
|
||||||
|
const intakeDate = intake.intakeTime;
|
||||||
|
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
|
||||||
|
if (intake.takenBy) {
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
|
||||||
|
}
|
||||||
|
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoMarkDueIntakesAsTaken(
|
||||||
|
settings: UserSettings & { userId: number },
|
||||||
|
rows: (typeof medications.$inferSelect)[],
|
||||||
|
locale: string,
|
||||||
|
tz: string,
|
||||||
|
logger: ServiceLogger
|
||||||
|
): Promise<number> {
|
||||||
|
if (settings.stockCalculationMode !== "automatic") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const existingToday = await db
|
||||||
|
.select({ doseId: doseTracking.doseId })
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, settings.userId),
|
||||||
|
gte(doseTracking.takenAt, todayStart),
|
||||||
|
lte(doseTracking.takenAt, todayEnd)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
|
||||||
|
for (const med of rows) {
|
||||||
|
if (med.isObsolete) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
med.intakesJson,
|
||||||
|
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
|
||||||
|
med.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
if (intakes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||||
|
const todaysIntakes = getTodaysIntakes(
|
||||||
|
med.name,
|
||||||
|
intakes,
|
||||||
|
medicationTakenBy,
|
||||||
|
med.pillWeightMg,
|
||||||
|
locale,
|
||||||
|
tz,
|
||||||
|
med.id,
|
||||||
|
med.doseUnit ?? "mg"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const intake of todaysIntakes) {
|
||||||
|
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doseId = buildDoseIdForIntake({
|
||||||
|
...intake,
|
||||||
|
medicationId: intake.medicationId,
|
||||||
|
blisterIndex: intake.blisterIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDoseIds.has(doseId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: settings.userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: intake.intakeTime,
|
||||||
|
markedBy: null,
|
||||||
|
takenSource: "automatic",
|
||||||
|
dismissed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
existingDoseIds.add(doseId);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inserted > 0) {
|
||||||
|
logger.info(`[IntakeReminder] User ${settings.userId}: Auto-marked ${inserted} due intake dose(s) as taken`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendIntakeReminderEmail(
|
async function sendIntakeReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
intakes: UpcomingIntake[],
|
intakes: UpcomingIntake[],
|
||||||
@@ -246,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(medications)
|
||||||
|
.where(eq(medications.userId, settings.userId))
|
||||||
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const locale = getDateLocale(language);
|
||||||
|
const tz = getTimezone();
|
||||||
|
|
||||||
|
await autoMarkDueIntakesAsTaken(settings, rows, locale, tz, logger);
|
||||||
|
|
||||||
// Check if any intake reminder notifications are enabled (granular check)
|
// Check if any intake reminder notifications are enabled (granular check)
|
||||||
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
|
||||||
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders;
|
||||||
@@ -262,11 +380,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get all medications with intake reminders enabled for this user
|
// Get all medications with intake reminders enabled for this user
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(medications)
|
|
||||||
.where(eq(medications.userId, settings.userId))
|
|
||||||
.orderBy(medications.id);
|
|
||||||
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
|
||||||
|
|
||||||
if (medsWithReminders.length === 0) {
|
if (medsWithReminders.length === 0) {
|
||||||
@@ -280,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
|
|||||||
|
|
||||||
const state = loadIntakeReminderState();
|
const state = loadIntakeReminderState();
|
||||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
const locale = getDateLocale(language);
|
|
||||||
const tz = getTimezone();
|
|
||||||
|
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/db-utils.js";
|
import { getDataDir } from "../db/db-utils.js";
|
||||||
import { medications, userSettings } from "../db/schema.js";
|
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||||
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
import { getFooterHtml, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||||
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
@@ -19,8 +19,10 @@ import {
|
|||||||
getNextScheduledTime,
|
getNextScheduledTime,
|
||||||
getTimezone,
|
getTimezone,
|
||||||
getTodayInTimezone,
|
getTodayInTimezone,
|
||||||
parseBlisters,
|
parseIntakesJson,
|
||||||
|
parseLocalDateTime,
|
||||||
parseReminderState,
|
parseReminderState,
|
||||||
|
parseTakenByJson,
|
||||||
type ReminderState,
|
type ReminderState,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
|
|
||||||
@@ -119,10 +121,6 @@ export async function updateUserReminderSentTime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBlistersFromRow(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
|
||||||
return parseBlisters(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
@@ -142,7 +140,8 @@ async function getMedicationsNeedingReminder(
|
|||||||
userId: number,
|
userId: number,
|
||||||
reminderDaysBefore: number,
|
reminderDaysBefore: number,
|
||||||
lowStockDays: number,
|
lowStockDays: number,
|
||||||
language: Language
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
): Promise<LowStockItem[]> {
|
): Promise<LowStockItem[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -150,15 +149,144 @@ async function getMedicationsNeedingReminder(
|
|||||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||||
.orderBy(medications.id);
|
.orderBy(medications.id);
|
||||||
|
|
||||||
|
const takenDoseRows = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
|
||||||
|
|
||||||
|
const takenDoseIdsByMed = new Map<number, Set<string>>();
|
||||||
|
const takenDoseTimestamps = new Map<string, number>();
|
||||||
|
for (const dose of takenDoseRows) {
|
||||||
|
const parts = dose.doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const medId = parseInt(parts[0], 10);
|
||||||
|
if (Number.isNaN(medId)) continue;
|
||||||
|
|
||||||
|
if (!takenDoseIdsByMed.has(medId)) {
|
||||||
|
takenDoseIdsByMed.set(medId, new Set());
|
||||||
|
}
|
||||||
|
takenDoseIdsByMed.get(medId)!.add(dose.doseId);
|
||||||
|
const rawTakenAt = Number(dose.takenAt);
|
||||||
|
const takenAtMs = Number.isFinite(rawTakenAt)
|
||||||
|
? rawTakenAt < 1_000_000_000_000
|
||||||
|
? rawTakenAt * 1000
|
||||||
|
: rawTakenAt
|
||||||
|
: new Date(dose.takenAt).getTime();
|
||||||
|
takenDoseTimestamps.set(dose.doseId, takenAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
const lowStock: LowStockItem[] = [];
|
const lowStock: LowStockItem[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const intakes = parseIntakesJson(
|
||||||
const totalPills =
|
row.intakesJson,
|
||||||
|
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
|
||||||
|
row.intakeRemindersEnabled ?? false
|
||||||
|
);
|
||||||
|
const blisters: Blister[] = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
|
||||||
|
|
||||||
|
const originalTotalPills =
|
||||||
(row.packageType ?? "blister") === "bottle"
|
(row.packageType ?? "blister") === "bottle"
|
||||||
? row.looseTablets + (row.stockAdjustment ?? 0)
|
? row.looseTablets + (row.stockAdjustment ?? 0)
|
||||||
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
: row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
|
||||||
|
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
|
||||||
|
|
||||||
|
let consumed = 0;
|
||||||
|
|
||||||
|
if (stockCalculationMode === "automatic") {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||||
|
if (Number.isNaN(blisterStart)) return;
|
||||||
|
|
||||||
|
const period = Math.max(1, blister.every) * msPerDay;
|
||||||
|
|
||||||
|
let effectiveStart: number;
|
||||||
|
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||||
|
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||||
|
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||||
|
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||||
|
} else {
|
||||||
|
effectiveStart = blisterStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intake = intakes[blisterIdx];
|
||||||
|
const intakePerson = intake?.takenBy;
|
||||||
|
const fallbackPeople = parseTakenByJson(row.takenByJson);
|
||||||
|
const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople.length > 0 ? fallbackPeople : [null];
|
||||||
|
|
||||||
|
let timeBasedConsumed = 0;
|
||||||
|
let lastAutoConsumedDateMs = 0;
|
||||||
|
|
||||||
|
if (effectiveStart <= now) {
|
||||||
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||||
|
timeBasedConsumed = occurrences * blister.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);
|
||||||
|
|
||||||
|
let earlyTakenConsumed = 0;
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const bIdx = parseInt(parts[1], 10);
|
||||||
|
const timestamp = parseInt(parts[2], 10);
|
||||||
|
if (!Number.isNaN(bIdx) && !Number.isNaN(timestamp) && bIdx === blisterIdx && timestamp > earlyCutoff) {
|
||||||
|
earlyTakenConsumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed += timeBasedConsumed + earlyTakenConsumed;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blisters.forEach((blister, blisterIdx) => {
|
||||||
|
const blisterStart = parseLocalDateTime(blister.start);
|
||||||
|
const blisterStartDateOnly = new Date(
|
||||||
|
blisterStart.getFullYear(),
|
||||||
|
blisterStart.getMonth(),
|
||||||
|
blisterStart.getDate()
|
||||||
|
).getTime();
|
||||||
|
if (Number.isNaN(blisterStartDateOnly)) return;
|
||||||
|
|
||||||
|
for (const doseId of takenDoseIds) {
|
||||||
|
const parts = doseId.split("-");
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
|
||||||
|
const parsedBlisterIdx = parseInt(parts[1], 10);
|
||||||
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
|
if (Number.isNaN(parsedBlisterIdx) || Number.isNaN(doseTimestamp) || parsedBlisterIdx !== blisterIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const takenAt = takenDoseTimestamps.get(doseId) ?? 0;
|
||||||
|
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
|
||||||
|
if (doseTimestamp >= blisterStartDateOnly && afterCorrectionOrNoCorrection) {
|
||||||
|
consumed += blister.usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPills = Math.max(0, originalTotalPills - consumed);
|
||||||
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: currentPills, blisters }, language);
|
||||||
|
|
||||||
if (daysLeft === null) continue;
|
if (daysLeft === null) continue;
|
||||||
|
|
||||||
@@ -168,7 +296,7 @@ async function getMedicationsNeedingReminder(
|
|||||||
if (isCritical || isLow) {
|
if (isCritical || isLow) {
|
||||||
lowStock.push({
|
lowStock.push({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
medsLeft: totalPills,
|
medsLeft: currentPills,
|
||||||
daysLeft,
|
daysLeft,
|
||||||
depletionDate,
|
depletionDate,
|
||||||
isCritical,
|
isCritical,
|
||||||
@@ -200,6 +328,25 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only hook to validate scheduler stock semantics against planner/coverage behavior.
|
||||||
|
export async function getMedicationsNeedingReminderForTests(
|
||||||
|
userId: number,
|
||||||
|
reminderDaysBefore: number,
|
||||||
|
lowStockDays: number,
|
||||||
|
language: Language,
|
||||||
|
stockCalculationMode: "automatic" | "manual"
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
name: string;
|
||||||
|
medsLeft: number;
|
||||||
|
daysLeft: number | null;
|
||||||
|
depletionDate: string | null;
|
||||||
|
isCritical: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return getMedicationsNeedingReminder(userId, reminderDaysBefore, lowStockDays, language, stockCalculationMode);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendReminderEmail(
|
async function sendReminderEmail(
|
||||||
email: string,
|
email: string,
|
||||||
lowStock: LowStockItem[],
|
lowStock: LowStockItem[],
|
||||||
@@ -403,7 +550,8 @@ async function checkAndSendReminderForUser(
|
|||||||
settings.userId,
|
settings.userId,
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
settings.lowStockDays,
|
settings.lowStockDays,
|
||||||
language
|
language,
|
||||||
|
settings.stockCalculationMode
|
||||||
);
|
);
|
||||||
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const client = createClient({ url: ":memory:" });
|
||||||
|
const db = drizzle(client);
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: false,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
vi.mock("../plugins/auth.js", () => ({
|
||||||
|
requireAuth: async () => {},
|
||||||
|
getAnonymousUserId: async () => 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { medicationRoutes } = await import("../routes/medications.js");
|
||||||
|
const { getMedicationsNeedingReminderForTests } = await import("../services/reminder-scheduler.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedAnonymousUser() {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (id, username, auth_provider, is_active) VALUES (?, ?, ?, 1)",
|
||||||
|
args: [1, "anon", "anonymous"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStockMode(mode: "automatic" | "manual") {
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode, reminder_days_before, low_stock_days, language)
|
||||||
|
VALUES (?, ?, 7, 365, 'en')`,
|
||||||
|
args: [1, mode],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMedication(options: {
|
||||||
|
name: string;
|
||||||
|
packCount?: number;
|
||||||
|
blistersPerPack?: number;
|
||||||
|
pillsPerBlister?: number;
|
||||||
|
looseTablets?: number;
|
||||||
|
stockAdjustment?: number;
|
||||||
|
lastStockCorrectionAt?: number | null;
|
||||||
|
isObsolete?: boolean;
|
||||||
|
takenBy?: string[];
|
||||||
|
intakes: Array<{ usage: number; every: number; start: string; takenBy?: string | null }>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
packCount = 1,
|
||||||
|
blistersPerPack = 1,
|
||||||
|
pillsPerBlister = 10,
|
||||||
|
looseTablets = 0,
|
||||||
|
stockAdjustment = 0,
|
||||||
|
lastStockCorrectionAt = null,
|
||||||
|
isObsolete = false,
|
||||||
|
takenBy = [],
|
||||||
|
intakes,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const usageJson = JSON.stringify(intakes.map((i) => i.usage));
|
||||||
|
const everyJson = JSON.stringify(intakes.map((i) => i.every));
|
||||||
|
const startJson = JSON.stringify(intakes.map((i) => i.start));
|
||||||
|
const intakesJson = JSON.stringify(
|
||||||
|
intakes.map((i) => ({
|
||||||
|
usage: i.usage,
|
||||||
|
every: i.every,
|
||||||
|
start: i.start,
|
||||||
|
takenBy: i.takenBy ?? null,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, taken_by_json, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
stock_adjustment, last_stock_correction_at,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
is_obsolete, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
name,
|
||||||
|
JSON.stringify(takenBy),
|
||||||
|
packCount,
|
||||||
|
blistersPerPack,
|
||||||
|
pillsPerBlister,
|
||||||
|
looseTablets,
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt,
|
||||||
|
usageJson,
|
||||||
|
everyJson,
|
||||||
|
startJson,
|
||||||
|
intakesJson,
|
||||||
|
isObsolete ? 1 : 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markDoseTaken(options: {
|
||||||
|
medicationId: number;
|
||||||
|
blisterIdx: number;
|
||||||
|
doseDateOnlyMs: number;
|
||||||
|
takenAtMs: number;
|
||||||
|
personSuffix?: string;
|
||||||
|
}) {
|
||||||
|
const { medicationId, blisterIdx, doseDateOnlyMs, takenAtMs, personSuffix } = options;
|
||||||
|
const baseId = `${medicationId}-${blisterIdx}-${doseDateOnlyMs}`;
|
||||||
|
const doseId = personSuffix ? `${baseId}-${personSuffix}` : baseId;
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, 0)",
|
||||||
|
args: [1, doseId, Math.floor(takenAtMs / 1000)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsageRow(app: FastifyInstance, startDate: string, endDate: string, medicationName: string) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate, endDate },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const rows = response.json();
|
||||||
|
const row = rows.find((r: { medicationName: string }) => r.medicationName === medicationName);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnlyMs(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(medicationRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTables();
|
||||||
|
await seedAnonymousUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps automatic mode current stock in sync", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Auto Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(usageRow.totalPills);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manual mode current stock in sync and does not auto-consume", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Sync";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(10);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects lastStockCorrectionAt cutoff in manual mode by takenAt", async () => {
|
||||||
|
await setStockMode("manual");
|
||||||
|
const medName = "Manual Correction";
|
||||||
|
const correctionMs = new Date("2026-01-05T12:00:00.000Z").getTime();
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
lastStockCorrectionAt: correctionMs,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jan5DateOnly = toDateOnlyMs(new Date("2026-01-05T00:00:00.000Z"));
|
||||||
|
const jan6DateOnly = toDateOnlyMs(new Date("2026-01-06T00:00:00.000Z"));
|
||||||
|
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan5DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-05T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: jan6DateOnly,
|
||||||
|
takenAtMs: new Date("2026-01-06T10:00:00.000Z").getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "manual");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts early taken dose in automatic mode without drift", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Early Taken";
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
tomorrow.setHours(20, 0, 0, 0);
|
||||||
|
const medicationId = await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: tomorrow.toISOString().slice(0, 19) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tomorrowDateOnly = toDateOnlyMs(tomorrow);
|
||||||
|
await markDoseTaken({
|
||||||
|
medicationId,
|
||||||
|
blisterIdx: 0,
|
||||||
|
doseDateOnlyMs: tomorrowDateOnly,
|
||||||
|
takenAtMs: now.getTime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rangeStart = new Date(now);
|
||||||
|
rangeStart.setDate(now.getDate() - 1);
|
||||||
|
const rangeEnd = new Date(now);
|
||||||
|
rangeEnd.setDate(now.getDate() + 7);
|
||||||
|
const usageRow = await getUsageRow(app, rangeStart.toISOString(), rangeEnd.toISOString(), medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(9);
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed intake-level and fallback takenBy consistently", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
const medName = "Mixed TakenBy";
|
||||||
|
await createMedication({
|
||||||
|
name: medName,
|
||||||
|
packCount: 2,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
takenBy: ["Alice", "Bob"],
|
||||||
|
intakes: [
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T08:00:00", takenBy: "Alice" },
|
||||||
|
{ usage: 1, every: 1, start: "2026-01-01T20:00:00", takenBy: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const usageRow = await getUsageRow(app, "2026-01-01T00:00:00.000Z", "2026-01-31T23:59:59.999Z", medName);
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
const schedulerRow = lowStock.find((r) => r.name === medName);
|
||||||
|
|
||||||
|
expect(schedulerRow).toBeDefined();
|
||||||
|
expect(usageRow.currentPills).toBe(schedulerRow!.medsLeft);
|
||||||
|
expect(usageRow.currentPills).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes obsolete medications from planner usage and scheduler", async () => {
|
||||||
|
await setStockMode("automatic");
|
||||||
|
await createMedication({
|
||||||
|
name: "Obsolete Med",
|
||||||
|
isObsolete: true,
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/usage",
|
||||||
|
payload: { startDate: "2026-01-01T00:00:00.000Z", endDate: "2026-01-31T23:59:59.999Z" },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().some((r: { medicationName: string }) => r.medicationName === "Obsolete Med")).toBe(false);
|
||||||
|
|
||||||
|
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||||
|
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.0",
|
"version": "1.14.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -756,7 +756,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
|
|
||||||
<div className="profile-actions">
|
<div className="profile-actions">
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
{t("common.cancel", "Cancel")}
|
{t("common.close", "Close")}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
|
||||||
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function ConfirmModal({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal-content"
|
className="modal-content confirm-modal"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
style={{ maxWidth: "450px" }}
|
style={{ maxWidth: "450px" }}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{t("exportImport.cancelButton")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,31 +15,12 @@ import type { Coverage, Medication, RefillEntry, StockThresholds } from "../type
|
|||||||
import { getMedTotal, getPackageSize } from "../types";
|
import { getMedTotal, getPackageSize } from "../types";
|
||||||
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Local Helper Functions
|
// Local Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate blister stock - divides current pills into full blisters and partial
|
|
||||||
*/
|
|
||||||
function getBlisterStock(
|
|
||||||
currentPills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
originalLooseTablets: number,
|
|
||||||
_originalTotalPills: number
|
|
||||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
|
||||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
|
||||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
|
||||||
}
|
|
||||||
const safeCurrent = Math.max(0, currentPills);
|
|
||||||
const loosePills = Math.min(safeCurrent, Math.max(0, originalLooseTablets));
|
|
||||||
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
|
||||||
const fullBlisters = Math.floor(sealedPills / pillsPerBlister);
|
|
||||||
const openBlisterPills = sealedPills % pillsPerBlister;
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format full blisters column
|
* Format full blisters column
|
||||||
*/
|
*/
|
||||||
@@ -230,14 +211,12 @@ export function MedDetailModal({
|
|||||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||||
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
|
||||||
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass;
|
||||||
const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize);
|
const stock = splitCurrentBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets);
|
||||||
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
const currentFullBlisters = Math.max(0, stock.fullBlisters);
|
||||||
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
const currentPartialPills = Math.max(0, stock.openBlisterPills);
|
||||||
const currentLoosePills = Math.max(0, stock.loosePills);
|
const currentLoosePills = Math.max(0, stock.loosePills);
|
||||||
const pillsPerPack = Math.max(1, selectedMed.blistersPerPack * selectedMed.pillsPerBlister);
|
|
||||||
const remainingPacks = Math.max(0, Math.ceil(Math.max(0, currentStock) / pillsPerPack));
|
|
||||||
const stockDisplayTotal =
|
const stockDisplayTotal =
|
||||||
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, currentStock);
|
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, structuralMax);
|
||||||
const maxPartialPills = Math.min(
|
const maxPartialPills = Math.min(
|
||||||
Math.max(0, selectedMed.pillsPerBlister),
|
Math.max(0, selectedMed.pillsPerBlister),
|
||||||
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
|
||||||
@@ -647,7 +626,7 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="ghost" onClick={onCloseEditStockModal}>
|
<button className="ghost" onClick={onCloseEditStockModal}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
|
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
|
||||||
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
|
||||||
@@ -782,7 +761,7 @@ export function MedDetailModal({
|
|||||||
<>
|
<>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.packs")}</span>
|
<span className="med-detail-label">{t("modal.packs")}</span>
|
||||||
<span className="med-detail-value">{remainingPacks}</span>
|
<span className="med-detail-value">{selectedMed.packCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-detail-item">
|
<div className="med-detail-item">
|
||||||
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
|
||||||
@@ -824,6 +803,58 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Intake Schedule Section */}
|
||||||
|
{selectedMed.blisters.length > 0 && (
|
||||||
|
<div className="med-detail-section">
|
||||||
|
<h3>
|
||||||
|
{t("modal.intakeSchedule")}{" "}
|
||||||
|
{selectedMed.intakeRemindersEnabled && (
|
||||||
|
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
||||||
|
<Bell size={14} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="med-detail-schedules">
|
||||||
|
{selectedMed.blisters.map((blister, idx) => {
|
||||||
|
// When using new intakes format with per-intake takenBy,
|
||||||
|
// each intake already represents one person's dose — don't multiply.
|
||||||
|
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
||||||
|
const intake = selectedMed.intakes?.[idx];
|
||||||
|
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
||||||
|
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
||||||
|
const totalUsage = blister.usage * personCount;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="med-schedule-item">
|
||||||
|
<span className="med-schedule-usage">
|
||||||
|
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
||||||
|
{selectedMed.pillWeightMg &&
|
||||||
|
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||||
|
</span>
|
||||||
|
<span className="med-schedule-freq">
|
||||||
|
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
||||||
|
</span>
|
||||||
|
{hasPerIntakeTakenBy && intake.takenBy && (
|
||||||
|
<span className="med-schedule-person">{intake.takenBy}</span>
|
||||||
|
)}
|
||||||
|
{intake?.intakeRemindersEnabled && (
|
||||||
|
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
|
||||||
|
<Bell size={13} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="med-schedule-time">
|
||||||
|
{t("modal.at")}{" "}
|
||||||
|
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prescription Details Section */}
|
{/* Prescription Details Section */}
|
||||||
{selectedMed.prescriptionEnabled && (
|
{selectedMed.prescriptionEnabled && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
@@ -860,50 +891,6 @@ export function MedDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Intake Schedule Section */}
|
|
||||||
{selectedMed.blisters.length > 0 && (
|
|
||||||
<div className="med-detail-section">
|
|
||||||
<h3>
|
|
||||||
{t("modal.intakeSchedule")}{" "}
|
|
||||||
{selectedMed.intakeRemindersEnabled && (
|
|
||||||
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
|
|
||||||
<Bell size={14} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="med-detail-schedules">
|
|
||||||
{selectedMed.blisters.map((blister, idx) => {
|
|
||||||
// When using new intakes format with per-intake takenBy,
|
|
||||||
// each intake already represents one person's dose — don't multiply.
|
|
||||||
// For legacy intakes (no per-intake takenBy), multiply by personCount.
|
|
||||||
const intake = selectedMed.intakes?.[idx];
|
|
||||||
const hasPerIntakeTakenBy = !!intake?.takenBy;
|
|
||||||
const personCount = hasPerIntakeTakenBy ? 1 : Math.max(1, selectedMed.takenBy?.length || 1);
|
|
||||||
const totalUsage = blister.usage * personCount;
|
|
||||||
return (
|
|
||||||
<div key={idx} className="med-schedule-item">
|
|
||||||
<span className="med-schedule-usage">
|
|
||||||
{totalUsage} {totalUsage !== 1 ? t("common.pills") : t("common.pill")}
|
|
||||||
{selectedMed.pillWeightMg &&
|
|
||||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
|
||||||
</span>
|
|
||||||
<span className="med-schedule-freq">
|
|
||||||
{blister.every === 1 ? t("common.daily") : t("common.everyNDays", { count: blister.every })}
|
|
||||||
</span>
|
|
||||||
<span className="med-schedule-time">
|
|
||||||
{t("modal.at")}{" "}
|
|
||||||
{new Date(blister.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Coverage Status Section */}
|
{/* Coverage Status Section */}
|
||||||
{medCoverage && status && (
|
{medCoverage && status && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
@@ -930,10 +917,10 @@ export function MedDetailModal({
|
|||||||
{selectedMed.notes && (
|
{selectedMed.notes && (
|
||||||
<div className="med-detail-section">
|
<div className="med-detail-section">
|
||||||
<h3>
|
<h3>
|
||||||
|
{t("modal.notes")}{" "}
|
||||||
<span className="notes-icon notes-icon-static" aria-hidden="true">
|
<span className="notes-icon notes-icon-static" aria-hidden="true">
|
||||||
<NotebookPen size={14} />
|
<NotebookPen size={14} />
|
||||||
</span>{" "}
|
</span>
|
||||||
{t("modal.notes")}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="med-notes-content">{selectedMed.notes}</div>
|
<div className="med-notes-content">{selectedMed.notes}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -990,44 +977,45 @@ export function MedDetailModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Footer */}
|
</div>
|
||||||
<div className="med-detail-footer">
|
|
||||||
<button onClick={onClose}>{t("common.close")}</button>
|
{/* Footer */}
|
||||||
<div className="footer-actions">
|
<div className="med-detail-footer">
|
||||||
<button className="success" onClick={onOpenRefillModal}>
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
{t("refill.button")}
|
<div className="footer-actions">
|
||||||
|
<button className="success" onClick={onOpenRefillModal}>
|
||||||
|
{t("refill.button")}
|
||||||
|
</button>
|
||||||
|
{onOpenMedicationEdit && (
|
||||||
|
<button
|
||||||
|
className="info icon-only tooltip-trigger"
|
||||||
|
onClick={onOpenMedicationEdit}
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
data-tooltip={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Pencil size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
{onOpenMedicationEdit && (
|
)}
|
||||||
<button
|
{onOpenEditStockModal && (
|
||||||
className="info icon-only tooltip-trigger"
|
<button
|
||||||
onClick={onOpenMedicationEdit}
|
className="icon-stock-correction icon-only tooltip-trigger"
|
||||||
aria-label={t("common.edit")}
|
onClick={onOpenEditStockModal}
|
||||||
data-tooltip={t("common.edit")}
|
aria-label={t("editStock.buttonLabel")}
|
||||||
>
|
data-tooltip={t("editStock.buttonLabel")}
|
||||||
<Pencil size={18} aria-hidden="true" />
|
>
|
||||||
</button>
|
<FilePenLine size={18} aria-hidden="true" />
|
||||||
)}
|
</button>
|
||||||
{onOpenEditStockModal && (
|
)}
|
||||||
<button
|
{selectedMed.blisters.length > 0 && (
|
||||||
className="icon-stock-correction icon-only tooltip-trigger"
|
<button
|
||||||
onClick={onOpenEditStockModal}
|
className="secondary icon-only tooltip-trigger"
|
||||||
aria-label={t("editStock.buttonLabel")}
|
onClick={() => generateICS(selectedMed)}
|
||||||
data-tooltip={t("editStock.buttonLabel")}
|
aria-label={t("modal.exportTooltip")}
|
||||||
>
|
data-tooltip={t("modal.exportTooltip")}
|
||||||
<FilePenLine size={18} aria-hidden="true" />
|
>
|
||||||
</button>
|
<Calendar size={18} aria-hidden="true" />
|
||||||
)}
|
</button>
|
||||||
{selectedMed.blisters.length > 0 && (
|
)}
|
||||||
<button
|
|
||||||
className="secondary icon-only tooltip-trigger"
|
|
||||||
onClick={() => generateICS(selectedMed)}
|
|
||||||
aria-label={t("modal.exportTooltip")}
|
|
||||||
data-tooltip={t("modal.exportTooltip")}
|
|
||||||
>
|
|
||||||
<Calendar size={18} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1131,7 +1119,7 @@ export function MedDetailModal({
|
|||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button className="ghost" onClick={onCloseRefillModal}>
|
<button className="ghost" onClick={onCloseRefillModal}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<div className="refill-footer-right">
|
<div className="refill-footer-right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ type ReportData = Record<
|
|||||||
number,
|
number,
|
||||||
{
|
{
|
||||||
dosesTaken: number;
|
dosesTaken: number;
|
||||||
|
automaticDosesTaken: number;
|
||||||
dosesDismissed: number;
|
dosesDismissed: number;
|
||||||
firstDoseAt: string | null;
|
firstDoseAt: string | null;
|
||||||
lastDoseAt: string | null;
|
lastDoseAt: string | null;
|
||||||
@@ -256,7 +257,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="report-actions">
|
<div className="report-actions">
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -382,6 +383,9 @@ function generateTextReport(
|
|||||||
lines.push(h3(t("report.docIntakeHistory")));
|
lines.push(h3(t("report.docIntakeHistory")));
|
||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken)));
|
||||||
|
if (data.automaticDosesTaken > 0) {
|
||||||
|
lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken)));
|
||||||
|
}
|
||||||
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed)));
|
||||||
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
|
||||||
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt)));
|
||||||
@@ -580,6 +584,9 @@ function buildPrintHtml(
|
|||||||
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
|
||||||
s += `<table><tbody>`;
|
s += `<table><tbody>`;
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
|
||||||
|
if (data.automaticDosesTaken > 0) {
|
||||||
|
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
|
||||||
|
}
|
||||||
if (data.dosesDismissed > 0)
|
if (data.dosesDismissed > 0)
|
||||||
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
|
||||||
if (data.firstDoseAt)
|
if (data.firstDoseAt)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function ShareDialog({
|
|||||||
|
|
||||||
<div className="share-dialog-footer">
|
<div className="share-dialog-footer">
|
||||||
<button className="ghost" onClick={onClose}>
|
<button className="ghost" onClick={onClose}>
|
||||||
{t("common.cancel")}
|
{t("common.close")}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
||||||
import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types";
|
import type { Coverage, FormState, Medication, ScheduleEvent, StockThresholds } from "../types";
|
||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
|
||||||
@@ -72,6 +72,7 @@ export interface AppContextValue {
|
|||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -127,7 +128,7 @@ export interface AppContextValue {
|
|||||||
submitRefill: (
|
submitRefill: (
|
||||||
medId: number,
|
medId: number,
|
||||||
editingId: number | null,
|
editingId: number | null,
|
||||||
setForm: React.Dispatch<React.SetStateAction<any>>,
|
setForm: React.Dispatch<React.SetStateAction<FormState>>,
|
||||||
loadMeds: () => void,
|
loadMeds: () => void,
|
||||||
usePrescription?: boolean
|
usePrescription?: boolean
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
@@ -212,7 +213,17 @@ export interface AppContextValue {
|
|||||||
// Context
|
// Context
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const AppContext = createContext<AppContextValue | null>(null);
|
const APP_CONTEXT_SINGLETON_KEY = "__MEDASSIST_APP_CONTEXT_SINGLETON__";
|
||||||
|
|
||||||
|
const AppContext = (() => {
|
||||||
|
const globalRef = globalThis as typeof globalThis & {
|
||||||
|
[APP_CONTEXT_SINGLETON_KEY]?: React.Context<AppContextValue | null>;
|
||||||
|
};
|
||||||
|
if (!globalRef[APP_CONTEXT_SINGLETON_KEY]) {
|
||||||
|
globalRef[APP_CONTEXT_SINGLETON_KEY] = createContext<AppContextValue | null>(null);
|
||||||
|
}
|
||||||
|
return globalRef[APP_CONTEXT_SINGLETON_KEY];
|
||||||
|
})();
|
||||||
|
|
||||||
// Helper for user-specific localStorage keys
|
// Helper for user-specific localStorage keys
|
||||||
function userStorageKey(userId: number | undefined, key: string): string {
|
function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
@@ -732,6 +743,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
showClearMissedConfirm: doses.showClearMissedConfirm,
|
showClearMissedConfirm: doses.showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
|
||||||
getDoseId: doses.getDoseId,
|
getDoseId: doses.getDoseId,
|
||||||
|
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||||
countTakenDoses: doses.countTakenDoses,
|
countTakenDoses: doses.countTakenDoses,
|
||||||
markDoseTaken: doses.markDoseTaken,
|
markDoseTaken: doses.markDoseTaken,
|
||||||
undoDoseTaken: doses.undoDoseTaken,
|
undoDoseTaken: doses.undoDoseTaken,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
|
|||||||
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
|
||||||
export type { UseMedicationsReturn } from "./useMedications";
|
export type { UseMedicationsReturn } from "./useMedications";
|
||||||
export { useMedications } from "./useMedications";
|
export { useMedications } from "./useMedications";
|
||||||
|
export { useModalHistory } from "./useModalHistory";
|
||||||
export type { UseRefillReturn } from "./useRefill";
|
export type { UseRefillReturn } from "./useRefill";
|
||||||
export { useRefill } from "./useRefill";
|
export { useRefill } from "./useRefill";
|
||||||
export type { Settings, UseSettingsReturn } from "./useSettings";
|
export type { Settings, UseSettingsReturn } from "./useSettings";
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
|
|||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||||
takenDoseTimestamps: Map<string, number>;
|
takenDoseTimestamps: Map<string, number>;
|
||||||
|
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||||
dismissedDoses: Set<string>;
|
dismissedDoses: Set<string>;
|
||||||
showClearMissedConfirm: boolean;
|
showClearMissedConfirm: boolean;
|
||||||
setShowClearMissedConfirm: (show: boolean) => void;
|
setShowClearMissedConfirm: (show: boolean) => void;
|
||||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||||
|
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||||
markDoseTaken: (doseId: string) => Promise<void>;
|
markDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
|
|||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
const [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 [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const timestamps = new Map<string, number>();
|
const timestamps = new Map<string, number>();
|
||||||
|
const sources = new Map<string, "manual" | "automatic">();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
for (const d of data.doses) {
|
for (const d of data.doses) {
|
||||||
if (d.dismissed) {
|
if (d.dismissed) {
|
||||||
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
} else {
|
} else {
|
||||||
taken.add(d.doseId);
|
taken.add(d.doseId);
|
||||||
timestamps.set(d.doseId, d.takenAt);
|
timestamps.set(d.doseId, d.takenAt);
|
||||||
|
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setTakenDoseTimestamps(timestamps);
|
setTakenDoseTimestamps(timestamps);
|
||||||
|
setTakenDoseSources(sources);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
|
|||||||
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
return person ? `${baseDoseId}-${person}` : baseDoseId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isDoseTakenAutomatically = useCallback(
|
||||||
|
(doseId: string): boolean => {
|
||||||
|
return takenDoseSources.get(doseId) === "automatic";
|
||||||
|
},
|
||||||
|
[takenDoseSources]
|
||||||
|
);
|
||||||
|
|
||||||
// Count taken doses for a day/item
|
// Count taken doses for a day/item
|
||||||
const countTakenDoses = useCallback(
|
const countTakenDoses = useCallback(
|
||||||
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
|
||||||
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.set(doseId, Date.now());
|
next.set(doseId, Date.now());
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(doseId, "manual");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
mutationInFlightRef.current--;
|
mutationInFlightRef.current--;
|
||||||
// Re-sync with server after mutation completes
|
// Re-sync with server after mutation completes
|
||||||
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
|
|||||||
next.delete(doseId);
|
next.delete(doseId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setTakenDoseSources((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doseId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
|
|||||||
takenDoses,
|
takenDoses,
|
||||||
setTakenDoses,
|
setTakenDoses,
|
||||||
takenDoseTimestamps,
|
takenDoseTimestamps,
|
||||||
|
takenDoseSources,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
countTakenDoses,
|
countTakenDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a history entry when a modal opens so the browser back button closes it.
|
||||||
|
* On popstate (back), calls `onClose` to dismiss the modal.
|
||||||
|
*/
|
||||||
|
export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () => void) {
|
||||||
|
const pushedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
window.history.pushState({ modal: modalKey }, "");
|
||||||
|
pushedRef.current = true;
|
||||||
|
} else if (pushedRef.current) {
|
||||||
|
pushedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isOpen, modalKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
if (pushedRef.current) {
|
||||||
|
pushedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
}
|
||||||
@@ -249,11 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
const updatedSettings = {
|
const updatedSettings = { ...settingsToSave };
|
||||||
...settingsToSave,
|
|
||||||
emailEnabled: effectiveEmailEnabled,
|
|
||||||
shoutrrrEnabled: effectiveShoutrrrEnabled,
|
|
||||||
};
|
|
||||||
setSettings(updatedSettings);
|
setSettings(updatedSettings);
|
||||||
setSettingsSaving(false);
|
setSettingsSaving(false);
|
||||||
setSavedSettings(updatedSettings);
|
setSavedSettings(updatedSettings);
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Bearbeiten",
|
"editEntry": "Bearbeiten",
|
||||||
|
"editEntryWithName": "Bearbeiten: {{name}}",
|
||||||
"viewEntry": "Ansehen",
|
"viewEntry": "Ansehen",
|
||||||
"newEntry": "Neues Medikament",
|
"newEntry": "Neues Medikament",
|
||||||
"badge": "Packungen + lose Tabletten",
|
"badge": "Packungen + lose Tabletten",
|
||||||
@@ -350,6 +351,7 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
|
||||||
|
"automaticTaken": "Automatisch eingenommen",
|
||||||
"hasNotes": "Hat Notizen",
|
"hasNotes": "Hat Notizen",
|
||||||
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
|
||||||
"lightMode": "Zum hellen Modus wechseln",
|
"lightMode": "Zum hellen Modus wechseln",
|
||||||
@@ -462,7 +464,9 @@
|
|||||||
"pillsTotal": "{{count}} Tabletten gesamt",
|
"pillsTotal": "{{count}} Tabletten gesamt",
|
||||||
"pillsTotal_one": "{{count}} Tablette gesamt",
|
"pillsTotal_one": "{{count}} Tablette gesamt",
|
||||||
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
"pillsTotal_other": "{{count}} Tabletten gesamt",
|
||||||
"max": "max"
|
"max": "max",
|
||||||
|
"on": "An",
|
||||||
|
"off": "Aus"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Teilen",
|
"button": "Teilen",
|
||||||
@@ -645,6 +649,7 @@
|
|||||||
"docPrescriptionExpiry": "Rezeptablauf",
|
"docPrescriptionExpiry": "Rezeptablauf",
|
||||||
"docIntakeHistory": "Einnahme-Verlauf",
|
"docIntakeHistory": "Einnahme-Verlauf",
|
||||||
"docDosesTaken": "Eingenommene Dosen",
|
"docDosesTaken": "Eingenommene Dosen",
|
||||||
|
"docDosesTakenAutomatic": "Automatisch eingenommen",
|
||||||
"docDosesDismissed": "Verworfene Dosen",
|
"docDosesDismissed": "Verworfene Dosen",
|
||||||
"docFirstDose": "Erste Dosis",
|
"docFirstDose": "Erste Dosis",
|
||||||
"docLastDose": "Letzte Dosis",
|
"docLastDose": "Letzte Dosis",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"editEntry": "Edit",
|
"editEntry": "Edit",
|
||||||
|
"editEntryWithName": "Edit: {{name}}",
|
||||||
"viewEntry": "View",
|
"viewEntry": "View",
|
||||||
"newEntry": "New medication",
|
"newEntry": "New medication",
|
||||||
"badge": "Packs + loose pills",
|
"badge": "Packs + loose pills",
|
||||||
@@ -350,6 +351,7 @@
|
|||||||
},
|
},
|
||||||
"tooltips": {
|
"tooltips": {
|
||||||
"intakeReminders": "Intake reminders enabled",
|
"intakeReminders": "Intake reminders enabled",
|
||||||
|
"automaticTaken": "Automatically taken",
|
||||||
"hasNotes": "Has notes",
|
"hasNotes": "Has notes",
|
||||||
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
|
||||||
"lightMode": "Switch to light mode",
|
"lightMode": "Switch to light mode",
|
||||||
@@ -462,7 +464,9 @@
|
|||||||
"pillsTotal": "{{count}} pills total",
|
"pillsTotal": "{{count}} pills total",
|
||||||
"pillsTotal_one": "{{count}} pill total",
|
"pillsTotal_one": "{{count}} pill total",
|
||||||
"pillsTotal_other": "{{count}} pills total",
|
"pillsTotal_other": "{{count}} pills total",
|
||||||
"max": "max"
|
"max": "max",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Share",
|
"button": "Share",
|
||||||
@@ -645,6 +649,7 @@
|
|||||||
"docPrescriptionExpiry": "Prescription expiry",
|
"docPrescriptionExpiry": "Prescription expiry",
|
||||||
"docIntakeHistory": "Intake History",
|
"docIntakeHistory": "Intake History",
|
||||||
"docDosesTaken": "Doses taken",
|
"docDosesTaken": "Doses taken",
|
||||||
|
"docDosesTakenAutomatic": "Automatically taken",
|
||||||
"docDosesDismissed": "Doses dismissed",
|
"docDosesDismissed": "Doses dismissed",
|
||||||
"docFirstDose": "First dose",
|
"docFirstDose": "First dose",
|
||||||
"docLastDose": "Last dose",
|
"docLastDose": "Last dose",
|
||||||
|
|||||||
@@ -4,57 +4,17 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
import type { Coverage } from "../types";
|
import { useModalHistory } from "../hooks";
|
||||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||||
|
import {
|
||||||
// Helper for user-specific localStorage keys
|
formatFullBlisters,
|
||||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
formatOpenBlisterAndLoose,
|
||||||
return userId ? `user_${userId}_${key}` : key;
|
getBlisterStock,
|
||||||
}
|
getMedTotal,
|
||||||
|
getReminderStatusData,
|
||||||
// Helper function to calculate blister stock
|
userStorageKey,
|
||||||
export function getBlisterStock(
|
} from "./dashboard-helpers";
|
||||||
totalPills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
_looseTablets: number,
|
|
||||||
_originalTotal: number
|
|
||||||
) {
|
|
||||||
const fullBlisters = Math.floor(totalPills / pillsPerBlister);
|
|
||||||
const openBlisterPills = totalPills % pillsPerBlister;
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format full blisters
|
|
||||||
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
|
||||||
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to format open blister and loose pills
|
|
||||||
export function formatOpenBlisterAndLoose(
|
|
||||||
openBlisterPills: number,
|
|
||||||
loosePills: number,
|
|
||||||
pillsPerBlister: number,
|
|
||||||
t: (key: string) => string
|
|
||||||
): string {
|
|
||||||
if (openBlisterPills === 0 && loosePills === 0) return "-";
|
|
||||||
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total pills for a medication (packageType-aware)
|
|
||||||
export function getMedTotal(med: {
|
|
||||||
packCount: number;
|
|
||||||
blistersPerPack: number;
|
|
||||||
pillsPerBlister: number;
|
|
||||||
looseTablets: number;
|
|
||||||
stockAdjustment?: number | null;
|
|
||||||
packageType?: string;
|
|
||||||
}): number {
|
|
||||||
if (med.packageType === "bottle") {
|
|
||||||
return med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notification bell SVG icon (no emoji)
|
// Notification bell SVG icon (no emoji)
|
||||||
function NotificationBellIcon() {
|
function NotificationBellIcon() {
|
||||||
@@ -76,108 +36,6 @@ function NotificationBellIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get structured reminder status data
|
|
||||||
export function getReminderStatusData(
|
|
||||||
reminderDaysBefore: number,
|
|
||||||
lowStockDays: number,
|
|
||||||
_allLowCoverage: Coverage[],
|
|
||||||
allCoverage: Coverage[],
|
|
||||||
lastAutoEmailSent: string | null,
|
|
||||||
_lastNotificationType: string | null,
|
|
||||||
_lastNotificationChannel: string | null,
|
|
||||||
lastReminderMedName: string | null,
|
|
||||||
lastReminderTakenBy: string | null,
|
|
||||||
lastStockReminderSent: string | null,
|
|
||||||
_lastStockReminderChannel: string | null,
|
|
||||||
lastStockReminderMedNames: string | null,
|
|
||||||
t: (key: string, options?: Record<string, unknown>) => string,
|
|
||||||
locale: string
|
|
||||||
): {
|
|
||||||
status: { text: string; className: string };
|
|
||||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
|
||||||
lastStockSent: { date: string; medNames: string | null } | null;
|
|
||||||
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
|
||||||
} {
|
|
||||||
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
|
||||||
|
|
||||||
for (const c of allCoverage) {
|
|
||||||
if (c.medsLeft <= 0) {
|
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.daysLeft === null) continue;
|
|
||||||
|
|
||||||
const roundedDaysLeft = Math.round(c.daysLeft);
|
|
||||||
const isCritical = c.daysLeft <= reminderDaysBefore;
|
|
||||||
const isLow = c.daysLeft < lowStockDays;
|
|
||||||
if (!isCritical && !isLow) continue;
|
|
||||||
|
|
||||||
const existing = lowStockMap.get(c.name);
|
|
||||||
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
|
||||||
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
|
||||||
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
|
||||||
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
|
||||||
|
|
||||||
// Determine status
|
|
||||||
let status: { text: string; className: string };
|
|
||||||
if (criticalCount > 0) {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
|
||||||
className: "danger",
|
|
||||||
};
|
|
||||||
} else if (lowCount > 0) {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
|
||||||
className: "warning",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
status = {
|
|
||||||
text: t("dashboard.reminders.allOk"),
|
|
||||||
className: "success",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse last stock reminder sent info (from dedicated stock tracking columns)
|
|
||||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
|
||||||
if (lastStockReminderSent) {
|
|
||||||
const sentDate = new Date(lastStockReminderSent);
|
|
||||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
lastStockSent = {
|
|
||||||
date: formattedDate,
|
|
||||||
medNames: lastStockReminderMedNames,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse last intake reminder sent info (from intake tracking columns)
|
|
||||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
|
||||||
if (lastAutoEmailSent) {
|
|
||||||
const sentDate = new Date(lastAutoEmailSent);
|
|
||||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
lastIntakeSent = {
|
|
||||||
date: formattedDate,
|
|
||||||
medName: lastReminderMedName,
|
|
||||||
takenBy: lastReminderTakenBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -206,6 +64,7 @@ export function DashboardPage() {
|
|||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
getDayStockStatus,
|
getDayStockStatus,
|
||||||
getDoseId,
|
getDoseId,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
showClearMissedConfirm,
|
showClearMissedConfirm,
|
||||||
setShowClearMissedConfirm,
|
setShowClearMissedConfirm,
|
||||||
clearingMissed,
|
clearingMissed,
|
||||||
@@ -218,6 +77,8 @@ export function DashboardPage() {
|
|||||||
loadSettings,
|
loadSettings,
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
|
|
||||||
|
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
|
||||||
|
|
||||||
// Get structured reminder data
|
// Get structured reminder data
|
||||||
const reminderData = getReminderStatusData(
|
const reminderData = getReminderStatusData(
|
||||||
settings.reminderDaysBefore,
|
settings.reminderDaysBefore,
|
||||||
@@ -907,6 +768,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -926,6 +789,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1153,6 +1024,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -1172,6 +1045,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1362,6 +1243,8 @@ export function DashboardPage() {
|
|||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -1381,6 +1264,14 @@ export function DashboardPage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
|
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||||
import type { DoseUnit, Medication } from "../types";
|
import type { DoseUnit, Medication } from "../types";
|
||||||
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
|
||||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||||
@@ -125,6 +125,7 @@ export function MedicationsPage() {
|
|||||||
const [showObsolete, setShowObsolete] = useState(true);
|
const [showObsolete, setShowObsolete] = useState(true);
|
||||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||||
const [showReportModal, setShowReportModal] = useState(false);
|
const [showReportModal, setShowReportModal] = useState(false);
|
||||||
|
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -463,6 +464,20 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
// Reset form after successful save
|
// Reset form after successful save
|
||||||
if (!editingId) {
|
if (!editingId) {
|
||||||
|
const shouldCloseMobileModal = showEditModal && window.innerWidth <= 768;
|
||||||
|
if (shouldCloseMobileModal) {
|
||||||
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
|
closeConfirmedRef.current = true;
|
||||||
|
clearEditMedIdParam();
|
||||||
|
setShowEditModal(false);
|
||||||
|
setReadOnlyView(false);
|
||||||
|
setActiveTab("general");
|
||||||
|
setViewMode("grid");
|
||||||
|
resetForm();
|
||||||
|
window.history.back();
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
setViewMode("grid");
|
setViewMode("grid");
|
||||||
} else {
|
} else {
|
||||||
@@ -480,6 +495,8 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
|
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
setShowObsoleteConfirm(false);
|
setShowObsoleteConfirm(false);
|
||||||
@@ -497,6 +514,11 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
|
if (currentEditMedId) {
|
||||||
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
|
clearEditMedIdParam();
|
||||||
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -515,6 +537,10 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentEditMedId) {
|
||||||
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
|
processedEditMedIdRef.current = currentEditMedId;
|
||||||
|
}
|
||||||
clearEditMedIdParam();
|
clearEditMedIdParam();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -562,37 +588,12 @@ export function MedicationsPage() {
|
|||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [showEditModal, closeEditModal]);
|
}, [showEditModal, closeEditModal]);
|
||||||
|
|
||||||
// Handle edit button click - open modal on mobile, switch to form on desktop
|
|
||||||
const normalizeMedicationForEdit = useCallback(
|
|
||||||
(med: Medication): Medication => {
|
|
||||||
if (med.packageType !== "blister") return med;
|
|
||||||
|
|
||||||
const pillsPerPack = Math.max(1, med.blistersPerPack * med.pillsPerBlister);
|
|
||||||
const fallbackStock = Math.max(0, getMedTotal(med));
|
|
||||||
const currentStock = Math.max(0, Math.round(coverageByMed[med.name]?.medsLeft ?? fallbackStock));
|
|
||||||
const nextPackCount = Math.floor(currentStock / pillsPerPack);
|
|
||||||
const nextLooseTablets = currentStock % pillsPerPack;
|
|
||||||
|
|
||||||
if (nextPackCount === med.packCount && nextLooseTablets === med.looseTablets) {
|
|
||||||
return med;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...med,
|
|
||||||
packCount: nextPackCount,
|
|
||||||
looseTablets: nextLooseTablets,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[coverageByMed]
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleEditClick(med: Medication) {
|
function handleEditClick(med: Medication) {
|
||||||
const normalizedMed = normalizeMedicationForEdit(med);
|
|
||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
startEdit(normalizedMed, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
};
|
};
|
||||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||||
@@ -602,17 +603,16 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(normalizedMed, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewClick(med: Medication) {
|
function handleViewClick(med: Medication) {
|
||||||
const normalizedMed = normalizeMedicationForEdit(med);
|
|
||||||
if (formChanged) {
|
if (formChanged) {
|
||||||
pendingActionRef.current = () => {
|
pendingActionRef.current = () => {
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
startEdit(normalizedMed, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
};
|
};
|
||||||
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
|
||||||
@@ -622,7 +622,7 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(true);
|
setReadOnlyView(true);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(normalizedMed, openEditModal);
|
startEdit(med, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,13 +685,13 @@ export function MedicationsPage() {
|
|||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
startEdit(normalizeMedicationForEdit(medicationToEdit), openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
setViewMode("form");
|
setViewMode("form");
|
||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [allMeds, normalizeMedicationForEdit, openEditModal, searchParams, setSearchParams, startEdit]);
|
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Bell } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MedicationAvatar } from "../components";
|
import { MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
@@ -65,6 +66,7 @@ export function SchedulePage() {
|
|||||||
pastDays,
|
pastDays,
|
||||||
futureDays,
|
futureDays,
|
||||||
takenDoses,
|
takenDoses,
|
||||||
|
isDoseTakenAutomatically,
|
||||||
dismissedDoses,
|
dismissedDoses,
|
||||||
markDoseTaken,
|
markDoseTaken,
|
||||||
undoDoseTaken,
|
undoDoseTaken,
|
||||||
@@ -204,13 +206,15 @@ export function SchedulePage() {
|
|||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||||
return (
|
return (
|
||||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||||
{person && (
|
{person && (
|
||||||
@@ -230,6 +234,14 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span
|
||||||
|
className="info-tooltip"
|
||||||
|
data-tooltip={t("tooltips.automaticTaken")}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -365,13 +377,15 @@ export function SchedulePage() {
|
|||||||
className="reminder-icon info-tooltip"
|
className="reminder-icon info-tooltip"
|
||||||
data-tooltip={t("tooltips.intakeReminders")}
|
data-tooltip={t("tooltips.intakeReminders")}
|
||||||
>
|
>
|
||||||
🔔
|
<Bell size={14} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="dose-checks">
|
<div className="dose-checks">
|
||||||
{people.map((person) => {
|
{people.map((person) => {
|
||||||
const doseId = getDoseId(dose.id, person);
|
const doseId = getDoseId(dose.id, person);
|
||||||
const isTaken = takenDoses.has(doseId);
|
const isTaken = takenDoses.has(doseId);
|
||||||
|
const isAutomaticallyTaken =
|
||||||
|
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -395,6 +409,11 @@ export function SchedulePage() {
|
|||||||
onClick={() => undoDoseTaken(doseId)}
|
onClick={() => undoDoseTaken(doseId)}
|
||||||
title={t("common.undo")}
|
title={t("common.undo")}
|
||||||
>
|
>
|
||||||
|
{isAutomaticallyTaken && (
|
||||||
|
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||||
|
🤖
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
↩
|
↩
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
|
checked={settings.emailStockReminders}
|
||||||
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -100,9 +100,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrStockReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +114,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
|
checked={settings.emailIntakeReminders}
|
||||||
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -127,9 +125,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrIntakeReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -143,9 +139,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.emailPrescriptionReminders}
|
||||||
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
|
||||||
disabled={!settings.emailEnabled}
|
disabled={!settings.emailEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -156,11 +150,7 @@ export function SettingsPage() {
|
|||||||
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={
|
checked={settings.shoutrrrPrescriptionReminders}
|
||||||
settings.shoutrrrUrl && settings.shoutrrrEnabled
|
|
||||||
? settings.shoutrrrPrescriptionReminders
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
|
||||||
disabled={!settings.shoutrrrEnabled}
|
disabled={!settings.shoutrrrEnabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Coverage } from "../types";
|
||||||
|
import { getMedTotal as getMedTotalFromTypes } from "../types";
|
||||||
|
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||||
|
|
||||||
|
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||||
|
return userId ? `user_${userId}_${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlisterStock(
|
||||||
|
totalPills: number,
|
||||||
|
pillsPerBlister: number,
|
||||||
|
looseTablets: number,
|
||||||
|
_originalTotal: number
|
||||||
|
) {
|
||||||
|
return splitCurrentBlisterStock(totalPills, pillsPerBlister, looseTablets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullBlisters(count: number, t: (key: string) => string): string {
|
||||||
|
return `${count} ${count === 1 ? t("common.blister") : t("common.blisters")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOpenBlisterAndLoose(
|
||||||
|
openBlisterPills: number,
|
||||||
|
loosePills: number,
|
||||||
|
pillsPerBlister: number,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string {
|
||||||
|
if (openBlisterPills > 0 && loosePills > 0) {
|
||||||
|
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")} + ${loosePills} ${t("modal.loosePills")}`;
|
||||||
|
}
|
||||||
|
if (openBlisterPills > 0) {
|
||||||
|
return `${openBlisterPills} ${t("common.of")} ${pillsPerBlister} ${t("common.pills")}`;
|
||||||
|
}
|
||||||
|
if (loosePills > 0) {
|
||||||
|
return `${loosePills} ${t("modal.loosePills")}`;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMedTotal(med: {
|
||||||
|
packCount: number;
|
||||||
|
blistersPerPack: number;
|
||||||
|
pillsPerBlister: number;
|
||||||
|
looseTablets: number;
|
||||||
|
stockAdjustment?: number | null;
|
||||||
|
packageType?: string;
|
||||||
|
}): number {
|
||||||
|
return getMedTotalFromTypes(med);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReminderStatusData(
|
||||||
|
reminderDaysBefore: number,
|
||||||
|
lowStockDays: number,
|
||||||
|
_allLowCoverage: Coverage[],
|
||||||
|
allCoverage: Coverage[],
|
||||||
|
lastAutoEmailSent: string | null,
|
||||||
|
_lastNotificationType: string | null,
|
||||||
|
_lastNotificationChannel: string | null,
|
||||||
|
lastReminderMedName: string | null,
|
||||||
|
lastReminderTakenBy: string | null,
|
||||||
|
lastStockReminderSent: string | null,
|
||||||
|
_lastStockReminderChannel: string | null,
|
||||||
|
lastStockReminderMedNames: string | null,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
|
locale: string
|
||||||
|
): {
|
||||||
|
status: { text: string; className: string };
|
||||||
|
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||||
|
lastStockSent: { date: string; medNames: string | null } | null;
|
||||||
|
lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||||
|
} {
|
||||||
|
const lowStockMap = new Map<string, { name: string; daysLeft: number; isCritical: boolean }>();
|
||||||
|
|
||||||
|
for (const c of allCoverage) {
|
||||||
|
if (c.medsLeft <= 0) {
|
||||||
|
lowStockMap.set(c.name, { name: c.name, daysLeft: 0, isCritical: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.daysLeft === null) continue;
|
||||||
|
|
||||||
|
const roundedDaysLeft = Math.round(c.daysLeft);
|
||||||
|
const isCritical = c.daysLeft <= reminderDaysBefore;
|
||||||
|
const isLow = c.daysLeft < lowStockDays;
|
||||||
|
if (!isCritical && !isLow) continue;
|
||||||
|
|
||||||
|
const existing = lowStockMap.get(c.name);
|
||||||
|
if (!existing || roundedDaysLeft < existing.daysLeft || (isCritical && !existing.isCritical)) {
|
||||||
|
lowStockMap.set(c.name, { name: c.name, daysLeft: roundedDaysLeft, isCritical });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowStockMeds = Array.from(lowStockMap.values()).sort((a, b) => a.daysLeft - b.daysLeft);
|
||||||
|
const criticalCount = lowStockMeds.filter((m) => m.isCritical).length;
|
||||||
|
const lowCount = lowStockMeds.filter((m) => !m.isCritical).length;
|
||||||
|
|
||||||
|
let status: { text: string; className: string };
|
||||||
|
if (criticalCount > 0) {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.criticalMeds", { count: criticalCount }),
|
||||||
|
className: "danger",
|
||||||
|
};
|
||||||
|
} else if (lowCount > 0) {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.lowMeds", { count: lowCount }),
|
||||||
|
className: "warning",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
status = {
|
||||||
|
text: t("dashboard.reminders.allOk"),
|
||||||
|
className: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||||
|
if (lastStockReminderSent) {
|
||||||
|
const sentDate = new Date(lastStockReminderSent);
|
||||||
|
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
lastStockSent = {
|
||||||
|
date: formattedDate,
|
||||||
|
medNames: lastStockReminderMedNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||||
|
if (lastAutoEmailSent) {
|
||||||
|
const sentDate = new Date(lastAutoEmailSent);
|
||||||
|
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
lastIntakeSent = {
|
||||||
|
date: formattedDate,
|
||||||
|
medName: lastReminderMedName,
|
||||||
|
takenBy: lastReminderTakenBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, lowStockMeds, lastStockSent, lastIntakeSent };
|
||||||
|
}
|
||||||
+25
-9
@@ -4323,9 +4323,10 @@ button.has-validation-error {
|
|||||||
/* Modal base styles moved to styles/modals-base.css */
|
/* Modal base styles moved to styles/modals-base.css */
|
||||||
|
|
||||||
/* Medication Detail Modal */
|
/* Medication Detail Modal */
|
||||||
.med-detail-modal {
|
.modal-content.med-detail-modal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: min(100vw - 1rem, 520px);
|
width: min(100vw - 1rem, 711px);
|
||||||
|
max-width: 711px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -4668,6 +4669,22 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.med-schedule-time {
|
.med-schedule-time {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-person {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.med-schedule-bell {
|
||||||
|
color: var(--warning);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .med-schedule-bell {
|
||||||
|
color: #b45309;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-footer {
|
.med-detail-footer {
|
||||||
@@ -4684,12 +4701,12 @@ button.has-validation-error {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||||
margin: 0 -2rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile devices can report wide CSS viewports (e.g., 768px in device emulation).
|
/* Mobile devices can report wide CSS viewports (e.g., 768px in device emulation).
|
||||||
Use input modality instead of width-only breakpoints so the modal still fills the handset viewport. */
|
Use input modality instead of width-only breakpoints so the modal still fills the handset viewport. */
|
||||||
@media (hover: none) and (pointer: coarse) {
|
@media (hover: none) and (pointer: coarse) and (max-width: 500px) {
|
||||||
.med-detail-overlay {
|
.med-detail-overlay {
|
||||||
padding: 0.4rem;
|
padding: 0.4rem;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -4912,7 +4929,7 @@ button.has-validation-error {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin: 0 -1.5rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.med-detail-footer > button {
|
.med-detail-footer > button {
|
||||||
@@ -4938,7 +4955,7 @@ button.has-validation-error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hard mobile override for MedDetailModal: remove side frame and use full handset viewport. */
|
/* Hard mobile override for MedDetailModal: remove side frame and use full handset viewport. */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 500px) {
|
||||||
.modal-overlay.med-detail-overlay {
|
.modal-overlay.med-detail-overlay {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -4969,9 +4986,8 @@ button.has-validation-error {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
position: sticky;
|
position: relative;
|
||||||
bottom: 0;
|
z-index: 1;
|
||||||
z-index: 5;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,19 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content.confirm-modal {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: min(100%, 450px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.modal-content.confirm-modal {
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: min(85dvh, 85vh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -190,17 +190,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile Edit Modal */
|
/* Mobile Edit Modal */
|
||||||
|
.mobile-edit-overlay {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-modal {
|
.edit-modal {
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
max-height: 90vh;
|
max-height: none;
|
||||||
overflow-y: auto;
|
height: min(96dvh, 96vh);
|
||||||
padding: 0.75rem;
|
padding: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-modal-header {
|
.edit-modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -208,12 +218,91 @@
|
|||||||
.edit-modal-header h2 {
|
.edit-modal-header h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-edit-form.form-grid {
|
.mobile-edit-form.form-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
flex-direction: column;
|
||||||
gap: 0.75rem 1rem;
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .modal-footer {
|
||||||
|
border-top: none;
|
||||||
|
padding: 0.45rem 0.15rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .readonly-fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .readonly-fieldset.swiping-horizontal {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .mobile-tab-viewport {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .mobile-tab-track {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .mobile-tab-track:not(.is-swiping) {
|
||||||
|
transition: transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .mobile-tab-track > .form-tab-panel {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-right: 0.1rem;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .form-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .form-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .form-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: max-content;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-edit-form .form-tab-panel.active {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-edit-form.form-grid > label {
|
.mobile-edit-form.form-grid > label {
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ describe("UserProfile", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const cancelBtn = screen.getByText(/common\.cancel/i);
|
const cancelBtn = screen.getByText(/common\.close/i);
|
||||||
fireEvent.click(cancelBtn);
|
fireEvent.click(cancelBtn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ describe("ExportModal", () => {
|
|||||||
|
|
||||||
it("renders cancel button", () => {
|
it("renders cancel button", () => {
|
||||||
render(<ExportModal {...defaultProps} />);
|
render(<ExportModal {...defaultProps} />);
|
||||||
expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument();
|
expect(screen.getByText(/common\.close/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onClose when cancel button is clicked", () => {
|
it("calls onClose when cancel button is clicked", () => {
|
||||||
render(<ExportModal {...defaultProps} />);
|
render(<ExportModal {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
|
fireEvent.click(screen.getByText(/common\.close/i));
|
||||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,32 @@ describe("MedDetailModal", () => {
|
|||||||
const body = document.querySelector(".med-detail-body");
|
const body = document.querySelector(".med-detail-body");
|
||||||
expect(body).toBeInTheDocument();
|
expect(body).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows configured pack count in package details, independent from current stock", () => {
|
||||||
|
const medWithConfiguredPacks: Medication = {
|
||||||
|
...mockMedication,
|
||||||
|
packCount: 11,
|
||||||
|
blistersPerPack: 5,
|
||||||
|
pillsPerBlister: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowCurrentStockCoverage: Coverage = {
|
||||||
|
...mockCoverage,
|
||||||
|
medsLeft: 47,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MedDetailModal
|
||||||
|
{...defaultProps}
|
||||||
|
selectedMed={medWithConfiguredPacks}
|
||||||
|
coverage={{ all: [lowCurrentStockCoverage] }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const packsLabel = screen.getByText(/modal\.packs/i);
|
||||||
|
const packsValue = packsLabel.closest(".med-detail-item")?.querySelector(".med-detail-value");
|
||||||
|
expect(packsValue?.textContent).toBe("11");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("MedDetailModal without coverage", () => {
|
describe("MedDetailModal without coverage", () => {
|
||||||
@@ -744,7 +770,7 @@ describe("MedDetailModal stock overflow warning", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show overflow warning icon with live stock denominator", () => {
|
it("shows overflow warning icon when stock exceeds blister package capacity", () => {
|
||||||
const overflowCoverage: Coverage = {
|
const overflowCoverage: Coverage = {
|
||||||
name: "Test Med",
|
name: "Test Med",
|
||||||
medsLeft: 49,
|
medsLeft: 49,
|
||||||
@@ -756,9 +782,9 @@ describe("MedDetailModal stock overflow warning", () => {
|
|||||||
|
|
||||||
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
|
render(<MedDetailModal {...defaultProps} coverage={{ all: [overflowCoverage] }} />);
|
||||||
|
|
||||||
// Live denominator uses current stock, so overflow warning is not shown in detail row.
|
// For blister meds, denominator is package capacity (not current stock), so overflow is shown.
|
||||||
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
const warningIcon = document.querySelector(".info-tooltip.tooltip-align-left.warning-text");
|
||||||
expect(warningIcon).not.toBeInTheDocument();
|
expect(warningIcon).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show warning icon when stock is within package capacity", () => {
|
it("does not show warning icon when stock is within package capacity", () => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("ReportModal", () => {
|
|||||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||||
|
|
||||||
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
|
expect(screen.getByText(/report\.title/i)).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /common\.cancel/i }));
|
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ describe("ShareDialog", () => {
|
|||||||
|
|
||||||
it("calls onClose when close button is clicked", () => {
|
it("calls onClose when close button is clicked", () => {
|
||||||
render(<ShareDialog {...defaultProps} />);
|
render(<ShareDialog {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /common\.close/i }));
|
const closeButtons = screen.getAllByRole("button", { name: /common\.close/i });
|
||||||
|
fireEvent.click(closeButtons[closeButtons.length - 1]);
|
||||||
expect(defaultProps.onClose).toHaveBeenCalled();
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ describe("useSettings", () => {
|
|||||||
await result.current.saveSettings(mockEvent);
|
await result.current.saveSettings(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
// emailEnabled should be false in the saved state
|
// Local state preserves user choice; backend receives effective value via payload
|
||||||
expect(result.current.settings.emailEnabled).toBe(false);
|
expect(result.current.settings.emailEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-disables shoutrrr when URL is empty", async () => {
|
it("auto-disables shoutrrr when URL is empty", async () => {
|
||||||
@@ -274,7 +274,8 @@ describe("useSettings", () => {
|
|||||||
await result.current.saveSettings(mockEvent);
|
await result.current.saveSettings(mockEvent);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.settings.shoutrrrEnabled).toBe(false);
|
// Local state preserves user choice; backend receives effective value via payload
|
||||||
|
expect(result.current.settings.shoutrrrEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes reminder status on interval", async () => {
|
it("refreshes reminder status on interval", async () => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DashboardPage } from "../../pages/DashboardPage";
|
||||||
import {
|
import {
|
||||||
DashboardPage,
|
|
||||||
formatFullBlisters,
|
formatFullBlisters,
|
||||||
formatOpenBlisterAndLoose,
|
formatOpenBlisterAndLoose,
|
||||||
getBlisterStock,
|
getBlisterStock,
|
||||||
getMedTotal,
|
getMedTotal,
|
||||||
getReminderStatusData,
|
getReminderStatusData,
|
||||||
userStorageKey,
|
userStorageKey,
|
||||||
} from "../../pages/DashboardPage";
|
} from "../../pages/dashboard-helpers";
|
||||||
|
|
||||||
// Mock data for tests with medications
|
// Mock data for tests with medications
|
||||||
const mockMeds = [
|
const mockMeds = [
|
||||||
@@ -181,6 +181,7 @@ const createMockAppContext = (overrides = {}) => ({
|
|||||||
missedPastDoseIds: [],
|
missedPastDoseIds: [],
|
||||||
getDayStockStatus: vi.fn(() => "success"),
|
getDayStockStatus: vi.fn(() => "success"),
|
||||||
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
|
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
|
||||||
|
isDoseTakenAutomatically: vi.fn(() => false),
|
||||||
showClearMissedConfirm: false,
|
showClearMissedConfirm: false,
|
||||||
setShowClearMissedConfirm: vi.fn(),
|
setShowClearMissedConfirm: vi.fn(),
|
||||||
clearingMissed: false,
|
clearingMissed: false,
|
||||||
@@ -198,7 +199,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calculates blister stock breakdown", () => {
|
it("calculates blister stock breakdown", () => {
|
||||||
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 7 });
|
expect(getBlisterStock(27, 10, 0, 27)).toEqual({ fullBlisters: 2, openBlisterPills: 7, loosePills: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats blister and open blister labels", () => {
|
it("formats blister and open blister labels", () => {
|
||||||
@@ -206,7 +207,7 @@ describe("DashboardPage helper functions", () => {
|
|||||||
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
|
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
|
||||||
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
|
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
|
||||||
expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-");
|
expect(formatOpenBlisterAndLoose(0, 0, 10, t)).toBe("-");
|
||||||
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills");
|
expect(formatOpenBlisterAndLoose(4, 4, 10, t)).toBe("4 common.of 10 common.pills + 4 modal.loosePills");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes total pills for blister and bottle types", () => {
|
it("computes total pills for blister and bottle types", () => {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ const fetchMock = vi.fn();
|
|||||||
vi.mock("../../hooks", () => ({
|
vi.mock("../../hooks", () => ({
|
||||||
useMedicationForm: () => mockFormHookValue,
|
useMedicationForm: () => mockFormHookValue,
|
||||||
useUnsavedChangesWarning: () => ({}),
|
useUnsavedChangesWarning: () => ({}),
|
||||||
|
useModalHistory: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../context", () => ({
|
vi.mock("../../context", () => ({
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const createMockContext = (overrides = {}) => ({
|
|||||||
manuallyExpandedDays: new Set(),
|
manuallyExpandedDays: new Set(),
|
||||||
toggleDayCollapse: vi.fn(),
|
toggleDayCollapse: vi.fn(),
|
||||||
openUserFilter: vi.fn(),
|
openUserFilter: vi.fn(),
|
||||||
|
isDoseTakenAutomatically: vi.fn(() => false),
|
||||||
missedPastDoseIds: [],
|
missedPastDoseIds: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ describe("getBlisterStock", () => {
|
|||||||
|
|
||||||
const result = getBlisterStock(med);
|
const result = getBlisterStock(med);
|
||||||
expect(result.fullBlisters).toBe(2); // 25 / 10 = 2
|
expect(result.fullBlisters).toBe(2); // 25 / 10 = 2
|
||||||
expect(result.openBlisterPills).toBe(5); // 25 % 10 = 5
|
expect(result.openBlisterPills).toBe(0); // 20 % 10 = 0 after preserving loose tablets
|
||||||
expect(result.loosePills).toBe(5);
|
expect(result.loosePills).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export type PlannerRow = {
|
|||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
totalPills: number;
|
totalPills: number;
|
||||||
|
currentPills?: number;
|
||||||
plannerUsage: number;
|
plannerUsage: number;
|
||||||
blisterSize: number;
|
blisterSize: number;
|
||||||
blistersNeeded: number;
|
blistersNeeded: number;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import type { BlisterStock, Medication } from "../types";
|
import type { BlisterStock, Medication } from "../types";
|
||||||
|
import { getMedTotal } from "../types";
|
||||||
|
import { splitCurrentBlisterStock } from "./stock";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map timezone to region code (ISO 3166-1 alpha-2).
|
* Map timezone to region code (ISO 3166-1 alpha-2).
|
||||||
@@ -302,12 +304,7 @@ export function getExpiryClass(expiryDate: string | null | undefined, thresholdD
|
|||||||
* Calculate blister stock breakdown for a medication
|
* Calculate blister stock breakdown for a medication
|
||||||
*/
|
*/
|
||||||
export function getBlisterStock(med: Medication): BlisterStock {
|
export function getBlisterStock(med: Medication): BlisterStock {
|
||||||
const total =
|
return splitCurrentBlisterStock(getMedTotal(med), med.pillsPerBlister, med.looseTablets);
|
||||||
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
|
||||||
const bSize = med.pillsPerBlister;
|
|
||||||
const fullBlisters = Math.floor(total / bSize);
|
|
||||||
const openBlisterPills = total % bSize;
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills: openBlisterPills };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
export * from "./formatters";
|
export * from "./formatters";
|
||||||
export * from "./ics";
|
export * from "./ics";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
|
export * from "./stock";
|
||||||
export * from "./storage";
|
export * from "./storage";
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Medication } from "../types";
|
||||||
|
|
||||||
|
export type BlisterStockSplit = {
|
||||||
|
fullBlisters: number;
|
||||||
|
openBlisterPills: number;
|
||||||
|
loosePills: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split current blister stock into sealed full blisters, open blister pills,
|
||||||
|
* and loose pills using the configured loose-tablets baseline.
|
||||||
|
*/
|
||||||
|
export function splitCurrentBlisterStock(
|
||||||
|
currentPills: number,
|
||||||
|
pillsPerBlister: number,
|
||||||
|
configuredLooseTablets: number
|
||||||
|
): BlisterStockSplit {
|
||||||
|
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||||
|
return { fullBlisters: 0, openBlisterPills: 0, loosePills: Math.max(0, currentPills) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeCurrent = Math.max(0, currentPills);
|
||||||
|
const loosePills = Math.min(safeCurrent, Math.max(0, configuredLooseTablets));
|
||||||
|
const sealedPills = Math.max(0, safeCurrent - loosePills);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullBlisters: Math.floor(sealedPills / pillsPerBlister),
|
||||||
|
openBlisterPills: sealedPills % pillsPerBlister,
|
||||||
|
loosePills,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper when medication object already contains stock fields.
|
||||||
|
*/
|
||||||
|
export function getBlisterStockFromMedication(med: Medication): BlisterStockSplit {
|
||||||
|
const total =
|
||||||
|
(med.packageType === "bottle"
|
||||||
|
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||||
|
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0)) ?? 0;
|
||||||
|
|
||||||
|
return splitCurrentBlisterStock(total, med.pillsPerBlister, med.looseTablets);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user