Compare commits

...

13 Commits

Author SHA1 Message Date
Daniel Volz e346d60f39 chore: release v1.14.1 (#262) 2026-02-21 20:51:28 +01:00
Daniel Volz afb8e5028c fix: auto-mark intakes at due time and show robot marker (#261)
* fix: auto-mark intakes at due time and show robot marker

* test: add taken_source to integration schema

* test: align e2e route schema with taken_source
2026-02-21 20:45:05 +01:00
Daniel Volz 9ab077a037 chore: release v1.14.0 (#259) 2026-02-21 18:04:20 +01:00
Daniel Volz 976d7356ec feat: improve medication detail modal layout and display (#258)
Widen detail modal on desktop (711px, up from 500px) with max-width
override to beat modals-base.css specificity. Limit fullscreen mode
to actual phones (<=500px) instead of all screens <=900px. Move intake
schedule section before prescription details. Show per-intake takenBy
person and bell icon with proper warning color. Right-align time in
schedule rows. Move notes icon after label text. Replace emoji bell
icons with Lucide Bell component in SchedulePage and MobileEditModal.
Add common.on/common.off i18n keys.

Closes #254
2026-02-21 18:00:23 +01:00
Daniel Volz 943148fb49 feat: close modals with browser back button on mobile (#257)
* feat: close modals with browser back button on mobile

Create reusable useModalHistory hook that pushes history state when a
modal opens and listens for popstate to close it. Apply to ReportModal,
ClearMissedConfirm, ExportModal, ImportConfirm, and all modals using
ConfirmModal/ShareDialog/Auth/ExportModal base components. Escape key
handling was already in place for desktop.

Closes #253

* fix: update tests for renamed button labels and missing useModalHistory mock
2026-02-21 18:00:12 +01:00
Daniel Volz 94bd8bd6e8 feat: improve mobile edit modal swipe gestures and tab navigation (#256)
* feat: improve mobile edit modal swipe gestures and tab navigation

Replace React passive touch handlers with native non-passive
addEventListener via useEffect for reliable horizontal swipe blocking.
Reduce axis-lock threshold from 18-26px to 6px for more responsive
gesture detection. Remove isInteractive() guard so swipe works on
input fields. Add tab strip auto-scroll via scrollIntoView when
active tab changes. Fix vertical scrolling by changing readonly
fieldset from display:block to display:flex.

Closes #252

* fix: guard scrollIntoView for jsdom test compatibility
2026-02-21 18:00:02 +01:00
Daniel Volz 0cf1c5353e fix: notification channel toggles snap back after being enabled (#255)
* fix: notification channel toggles snap back after being enabled

The checked props for email/push notification toggles had redundant
conditions (smtpHost/shoutrrrUrl checks) that forced them to false,
causing immediate visual snap-back. Additionally, performSave()
overwrote emailEnabled/shoutrrrEnabled in local state with effective
values, disabling toggles when no SMTP host or Shoutrrr URL was set.

Remove redundant checked prop conditions (disabled attr already handles
interaction gating) and stop overwriting enabled flags in local state
after save.

Closes #250

* fix: remove leaked useModalHistory import from SettingsPage

* fix: update useSettings tests to match new toggle behavior
2026-02-21 17:59:50 +01:00
github-actions[bot] 98cf1ce1d2 chore: update test count badges [skip ci] 2026-02-21 14:51:05 +00:00
Daniel Volz 75c201cab5 fix: keep med detail stock and package values consistent (#249) 2026-02-21 15:47:44 +01:00
github-actions[bot] 74f079d13e chore: update test count badges [skip ci] 2026-02-21 14:28:27 +00:00
Daniel Volz fd3b770a81 fix: improve mobile edit modal scrolling behavior (#247) 2026-02-21 15:24:57 +01:00
Daniel Volz 612aa007aa fix: unify stock semantics across planner and scheduler (#245)
* fix: unify stock semantics across planner and scheduler

* fix: stabilize dashboard hmr and align stock helper tests
2026-02-21 15:24:53 +01:00
Daniel Volz 02af93ec55 chore: release v1.13.0 (#243) 2026-02-20 19:55:26 +01:00
53 changed files with 3076 additions and 837 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -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
+7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.12.0", "version": "1.14.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -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
+1
View File
@@ -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
}); });
+4
View File
@@ -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 };
+3
View File
@@ -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,
}); });
} }
+109 -57
View File
@@ -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,
+10 -2
View File
@@ -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 }));
+160 -12
View File
@@ -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);
+1
View File
@@ -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
)`, )`,
+1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.12.0", "version": "1.14.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -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")}
+1 -1
View File
@@ -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" }}
+1 -1
View File
@@ -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>
+98 -110
View File
@@ -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
+8 -1
View File
@@ -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)
+1 -1
View File
@@ -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")}
+15 -3
View File
@@ -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,
+1
View File
@@ -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";
+30
View File
@@ -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,
+32
View File
@@ -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]);
}
+1 -5
View File
@@ -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);
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+42 -151
View File
@@ -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>
) : ( ) : (
+33 -33
View File
@@ -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;
+21 -2
View File
@@ -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>
) : ( ) : (
+6 -16
View File
@@ -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}
/> />
+147
View File
@@ -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
View File
@@ -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;
} }
} }
+13
View File
@@ -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;
+96 -7
View File
@@ -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 {
+1 -1
View File
@@ -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();
}); });
+4 -3
View File
@@ -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,
}); });
+1 -1
View File
@@ -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);
}); });
+1
View File
@@ -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
View File
@@ -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 };
} }
/** /**
+1
View File
@@ -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";
+43
View File
@@ -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);
}