Compare commits

..

14 Commits

Author SHA1 Message Date
Daniel Volz 228fd4cd7e chore: release v1.14.2 (#263) 2026-02-21 20:56:12 +01:00
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 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/Frontend_Tests-776%2F776-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<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-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 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,
"tag": "0009_add_medication_start_date",
"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",
"version": "1.12.0",
"version": "1.14.2",
"private": true,
"type": "module",
"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`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`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)
`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
+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"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
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
});
+4
View File
@@ -56,6 +56,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
@@ -94,6 +95,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
return { success: true };
@@ -227,6 +229,7 @@ export async function doseRoutes(app: FastifyInstance) {
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
@@ -270,6 +273,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
takenSource: "manual",
});
return { success: true };
+3
View File
@@ -72,6 +72,7 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false),
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,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
@@ -625,6 +627,7 @@ export async function exportRoutes(app: FastifyInstance) {
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
+109 -57
View File
@@ -6,7 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.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 { env } from "../plugins/env.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)))
.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
const takenDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false)));
// Create a map of medication ID to taken dose count
const takenDosesMap = new Map<number, { blisterIdx: number; usage: number }[]>();
const takenDoseIdsByMed = new Map<number, Set<string>>();
const takenDoseTimestamps = new Map<string, number>();
takenDoses.forEach((dose) => {
const parts = dose.doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) {
if (!takenDosesMap.has(medId)) {
takenDosesMap.set(medId, []);
}
takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later
}
if (parts.length < 3) return;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) return;
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);
});
// Use current time as the reference point for "available" stock
@@ -838,66 +849,106 @@ export async function medicationRoutes(app: FastifyInstance) {
? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption based on ACTUAL taken doses from dose_tracking
// 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
// Calculate consumption with the same automatic/manual behavior as frontend coverage.
const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0;
// 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)
);
const takenDoseIds = takenDoseIdsByMed.get(row.id) ?? new Set<string>();
// Count consumed pills by generating expected doses and checking if they're taken
let consumedUntilNow = 0;
const msPerDay = 86400000;
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
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;
const period = Math.max(1, blister.every) * msPerDay;
// After a stock correction, start counting from the NEXT scheduled
// dose, because the user's pill count already reflects all
// consumption up to the correction time.
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart.getTime()) {
effectiveStart = stockCorrectionCutoff + period;
} else {
effectiveStart = blisterStart.getTime();
}
if (effectiveStart > now.getTime()) return;
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 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)
const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : [];
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null];
const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback;
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
// Generate expected dose IDs and check if they're taken
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`;
if (effectiveStart <= now.getTime()) {
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoseIds.has(doseId)) {
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;
}
}
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;
}
}
}
});
});
}
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
@@ -943,6 +994,7 @@ export async function medicationRoutes(app: FastifyInstance) {
medicationId: row.id,
medicationName: row.name,
totalPills: currentStock,
currentPills: currentStock,
plannerUsage: usageTotal,
blisterSize: pillsPerBlister,
blistersNeeded,
+10 -2
View File
@@ -51,17 +51,22 @@ export async function reportRoutes(app: FastifyInstance) {
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// 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) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
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
@@ -69,6 +74,7 @@ export async function reportRoutes(app: FastifyInstance) {
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
@@ -79,6 +85,7 @@ export async function reportRoutes(app: FastifyInstance) {
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const dismissedDoses = doses.filter((d) => d.dismissed);
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] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.length,
dosesDismissed: dismissedDoses.length,
firstDoseAt: sortedTaken.length > 0 ? new Date(sortedTaken[0]).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));
}
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(
email: string,
intakes: UpcomingIntake[],
@@ -246,6 +353,17 @@ async function checkAndSendIntakeRemindersForUser(
`[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)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
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
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
@@ -280,9 +393,6 @@ async function checkAndSendIntakeRemindersForUser(
const state = loadIntakeReminderState();
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)
const now = new Date();
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 { db } from "../db/client.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 { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import type { ServiceLogger } from "../utils/logger.js";
@@ -19,8 +19,10 @@ import {
getNextScheduledTime,
getTimezone,
getTodayInTimezone,
parseBlisters,
parseIntakesJson,
parseLocalDateTime,
parseReminderState,
parseTakenByJson,
type ReminderState,
} 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 = {
name: string;
medsLeft: number;
@@ -142,7 +140,8 @@ async function getMedicationsNeedingReminder(
userId: number,
reminderDaysBefore: number,
lowStockDays: number,
language: Language
language: Language,
stockCalculationMode: "automatic" | "manual"
): Promise<LowStockItem[]> {
const rows = await db
.select()
@@ -150,15 +149,144 @@ async function getMedicationsNeedingReminder(
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
.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 now = Date.now();
const msPerDay = 86_400_000;
for (const row of rows) {
const blisters = parseBlistersFromRow(row);
const totalPills =
const intakes = parseIntakesJson(
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.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;
@@ -168,7 +296,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) {
lowStock.push({
name: row.name,
medsLeft: totalPills,
medsLeft: currentPills,
daysLeft,
depletionDate,
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(
email: string,
lowStock: LowStockItem[],
@@ -403,7 +550,8 @@ async function checkAndSendReminderForUser(
settings.userId,
settings.reminderDaysBefore,
settings.lowStockDays,
language
language,
settings.stockCalculationMode
);
const allPrescriptionLow = await getMedicationsNeedingPrescriptionReminder(settings.userId);
+1
View File
@@ -171,6 +171,7 @@ async function createSchema(client: Client) {
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
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,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
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",
"private": true,
"version": "1.12.0",
"version": "1.14.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -756,7 +756,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
<div className="profile-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
{t("common.close", "Close")}
</button>
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
+1 -1
View File
@@ -47,7 +47,7 @@ export function ConfirmModal({
}}
>
<div
className="modal-content"
className="modal-content confirm-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
style={{ maxWidth: "450px" }}
+1 -1
View File
@@ -64,7 +64,7 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex
</div>
<div className="modal-footer" style={{ padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end" }}>
<button type="button" className="ghost" onClick={onClose}>
{t("exportImport.cancelButton")}
{t("common.close")}
</button>
</div>
</div>
+98 -110
View File
@@ -15,31 +15,12 @@ import type { Coverage, Medication, RefillEntry, StockThresholds } from "../type
import { getMedTotal, getPackageSize } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
// =============================================================================
// 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
*/
@@ -230,14 +211,12 @@ export function MedDetailModal({
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text";
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 currentPartialPills = Math.max(0, stock.openBlisterPills);
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 =
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, currentStock);
selectedMed.packageType === "bottle" ? (selectedMed.totalPills ?? packageSize) : Math.max(0, structuralMax);
const maxPartialPills = Math.min(
Math.max(0, selectedMed.pillsPerBlister),
Math.max(0, structuralMax - Math.max(0, editStockFullBlisters) * selectedMed.pillsPerBlister)
@@ -647,7 +626,7 @@ export function MedDetailModal({
<div className="modal-footer">
<button className="ghost" onClick={onCloseEditStockModal}>
{t("common.cancel")}
{t("common.close")}
</button>
<button className="info" onClick={() => onSubmitStockCorrection(selectedMed.id)} disabled={editStockSaving}>
{editStockSaving ? t("editStock.saving") : t("editStock.save")}
@@ -782,7 +761,7 @@ export function MedDetailModal({
<>
<div className="med-detail-item">
<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 className="med-detail-item">
<span className="med-detail-label">{t("modal.blistersPerPack")}</span>
@@ -824,6 +803,58 @@ 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>
{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 */}
{selectedMed.prescriptionEnabled && (
<div className="med-detail-section">
@@ -860,50 +891,6 @@ export function MedDetailModal({
</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 */}
{medCoverage && status && (
<div className="med-detail-section">
@@ -930,10 +917,10 @@ export function MedDetailModal({
{selectedMed.notes && (
<div className="med-detail-section">
<h3>
{t("modal.notes")}{" "}
<span className="notes-icon notes-icon-static" aria-hidden="true">
<NotebookPen size={14} />
</span>{" "}
{t("modal.notes")}
</span>
</h3>
<div className="med-notes-content">{selectedMed.notes}</div>
</div>
@@ -990,44 +977,45 @@ export function MedDetailModal({
)}
</div>
)}
{/* Footer */}
<div className="med-detail-footer">
<button onClick={onClose}>{t("common.close")}</button>
<div className="footer-actions">
<button className="success" onClick={onOpenRefillModal}>
{t("refill.button")}
</div>
{/* Footer */}
<div className="med-detail-footer">
<button onClick={onClose}>{t("common.close")}</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>
{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>
)}
{onOpenEditStockModal && (
<button
className="icon-stock-correction icon-only tooltip-trigger"
onClick={onOpenEditStockModal}
aria-label={t("editStock.buttonLabel")}
data-tooltip={t("editStock.buttonLabel")}
>
<FilePenLine 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>
)}
{onOpenEditStockModal && (
<button
className="icon-stock-correction icon-only tooltip-trigger"
onClick={onOpenEditStockModal}
aria-label={t("editStock.buttonLabel")}
data-tooltip={t("editStock.buttonLabel")}
>
<FilePenLine 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>
@@ -1131,7 +1119,7 @@ export function MedDetailModal({
<div className="modal-footer">
<button className="ghost" onClick={onCloseRefillModal}>
{t("common.cancel")}
{t("common.close")}
</button>
<div className="refill-footer-right">
<button
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -16,6 +16,7 @@ type ReportData = Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesDismissed: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
@@ -256,7 +257,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
{t("common.cancel")}
{t("common.close")}
</button>
<button
type="button"
@@ -382,6 +383,9 @@ function generateTextReport(
lines.push(h3(t("report.docIntakeHistory")));
if (data.dosesTaken > 0 || data.dosesDismissed > 0) {
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.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt)));
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) {
s += `<table><tbody>`;
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)
s += `<tr><td class="label">${escHtml(t("report.docDosesDismissed"))}</td><td>${data.dosesDismissed}</td></tr>`;
if (data.firstDoseAt)
+1 -1
View File
@@ -145,7 +145,7 @@ export function ShareDialog({
<div className="share-dialog-footer">
<button className="ghost" onClick={onClose}>
{t("common.cancel")}
{t("common.close")}
</button>
<button onClick={onGenerateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
{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 { useAuth } from "../components/Auth";
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 { log } from "../utils/logger";
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule";
@@ -72,6 +72,7 @@ export interface AppContextValue {
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
@@ -127,7 +128,7 @@ export interface AppContextValue {
submitRefill: (
medId: number,
editingId: number | null,
setForm: React.Dispatch<React.SetStateAction<any>>,
setForm: React.Dispatch<React.SetStateAction<FormState>>,
loadMeds: () => void,
usePrescription?: boolean
) => Promise<void>;
@@ -212,7 +213,17 @@ export interface AppContextValue {
// 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
function userStorageKey(userId: number | undefined, key: string): string {
@@ -732,6 +743,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
showClearMissedConfirm: doses.showClearMissedConfirm,
setShowClearMissedConfirm: doses.setShowClearMissedConfirm,
getDoseId: doses.getDoseId,
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
countTakenDoses: doses.countTakenDoses,
markDoseTaken: doses.markDoseTaken,
undoDoseTaken: doses.undoDoseTaken,
+1
View File
@@ -8,6 +8,7 @@ export type { UseMedicationFormReturn } from "./useMedicationForm";
export { defaultBlister, defaultForm, useMedicationForm } from "./useMedicationForm";
export type { UseMedicationsReturn } from "./useMedications";
export { useMedications } from "./useMedications";
export { useModalHistory } from "./useModalHistory";
export type { UseRefillReturn } from "./useRefill";
export { useRefill } from "./useRefill";
export type { Settings, UseSettingsReturn } from "./useSettings";
+30
View File
@@ -8,10 +8,12 @@ export interface UseDosesReturn {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
dismissedDoses: Set<string>;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
getDoseId: (baseDoseId: string, person: string | null) => string;
isDoseTakenAutomatically: (doseId: string) => boolean;
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
markDoseTaken: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
@@ -21,6 +23,7 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
@@ -42,6 +45,7 @@ export function useDoses(): UseDosesReturn {
const data = await res.json();
const taken = new Set<string>();
const timestamps = new Map<string, number>();
const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
@@ -49,10 +53,12 @@ export function useDoses(): UseDosesReturn {
} else {
taken.add(d.doseId);
timestamps.set(d.doseId, d.takenAt);
sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual");
}
}
setTakenDoses(taken);
setTakenDoseTimestamps(timestamps);
setTakenDoseSources(sources);
setDismissedDoses(dismissed);
}
// Don't reset on error - keep current state
@@ -75,6 +81,13 @@ export function useDoses(): UseDosesReturn {
return person ? `${baseDoseId}-${person}` : baseDoseId;
}, []);
const isDoseTakenAutomatically = useCallback(
(doseId: string): boolean => {
return takenDoseSources.get(doseId) === "automatic";
},
[takenDoseSources]
);
// Count taken doses for a day/item
const countTakenDoses = useCallback(
(doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => {
@@ -106,6 +119,11 @@ export function useDoses(): UseDosesReturn {
next.set(doseId, Date.now());
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.set(doseId, "manual");
return next;
});
// Send to server
try {
@@ -127,6 +145,11 @@ export function useDoses(): UseDosesReturn {
next.delete(doseId);
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
} finally {
mutationInFlightRef.current--;
// Re-sync with server after mutation completes
@@ -150,6 +173,11 @@ export function useDoses(): UseDosesReturn {
next.delete(doseId);
return next;
});
setTakenDoseSources((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
// Send to server
try {
@@ -177,10 +205,12 @@ export function useDoses(): UseDosesReturn {
takenDoses,
setTakenDoses,
takenDoseTimestamps,
takenDoseSources,
dismissedDoses,
showClearMissedConfirm,
setShowClearMissedConfirm,
getDoseId,
isDoseTakenAutomatically,
countTakenDoses,
markDoseTaken,
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),
}).catch(() => null);
const updatedSettings = {
...settingsToSave,
emailEnabled: effectiveEmailEnabled,
shoutrrrEnabled: effectiveShoutrrrEnabled,
};
const updatedSettings = { ...settingsToSave };
setSettings(updatedSettings);
setSettingsSaving(false);
setSavedSettings(updatedSettings);
+6 -1
View File
@@ -153,6 +153,7 @@
},
"form": {
"editEntry": "Bearbeiten",
"editEntryWithName": "Bearbeiten: {{name}}",
"viewEntry": "Ansehen",
"newEntry": "Neues Medikament",
"badge": "Packungen + lose Tabletten",
@@ -350,6 +351,7 @@
},
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"automaticTaken": "Automatisch eingenommen",
"hasNotes": "Hat Notizen",
"stockExceedsCapacity": "Bestand überschreitet Packungskapazität — Packungsanzahl anpassen",
"lightMode": "Zum hellen Modus wechseln",
@@ -462,7 +464,9 @@
"pillsTotal": "{{count}} Tabletten gesamt",
"pillsTotal_one": "{{count}} Tablette gesamt",
"pillsTotal_other": "{{count}} Tabletten gesamt",
"max": "max"
"max": "max",
"on": "An",
"off": "Aus"
},
"share": {
"button": "Teilen",
@@ -645,6 +649,7 @@
"docPrescriptionExpiry": "Rezeptablauf",
"docIntakeHistory": "Einnahme-Verlauf",
"docDosesTaken": "Eingenommene Dosen",
"docDosesTakenAutomatic": "Automatisch eingenommen",
"docDosesDismissed": "Verworfene Dosen",
"docFirstDose": "Erste Dosis",
"docLastDose": "Letzte Dosis",
+6 -1
View File
@@ -153,6 +153,7 @@
},
"form": {
"editEntry": "Edit",
"editEntryWithName": "Edit: {{name}}",
"viewEntry": "View",
"newEntry": "New medication",
"badge": "Packs + loose pills",
@@ -350,6 +351,7 @@
},
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"automaticTaken": "Automatically taken",
"hasNotes": "Has notes",
"stockExceedsCapacity": "Stock exceeds package capacity — consider updating pack count",
"lightMode": "Switch to light mode",
@@ -462,7 +464,9 @@
"pillsTotal": "{{count}} pills total",
"pillsTotal_one": "{{count}} pill total",
"pillsTotal_other": "{{count}} pills total",
"max": "max"
"max": "max",
"on": "On",
"off": "Off"
},
"share": {
"button": "Share",
@@ -645,6 +649,7 @@
"docPrescriptionExpiry": "Prescription expiry",
"docIntakeHistory": "Intake History",
"docDosesTaken": "Doses taken",
"docDosesTakenAutomatic": "Automatically taken",
"docDosesDismissed": "Doses dismissed",
"docFirstDose": "First dose",
"docLastDose": "Last dose",
+42 -151
View File
@@ -4,57 +4,17 @@ import { useTranslation } from "react-i18next";
import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { useModalHistory } from "../hooks";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
export function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
// Helper function to calculate blister stock
export function getBlisterStock(
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);
}
import {
formatFullBlisters,
formatOpenBlisterAndLoose,
getBlisterStock,
getMedTotal,
getReminderStatusData,
userStorageKey,
} from "./dashboard-helpers";
// Notification bell SVG icon (no emoji)
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() {
const { t, i18n } = useTranslation();
const { user } = useAuth();
@@ -206,6 +64,7 @@ export function DashboardPage() {
missedPastDoseIds,
getDayStockStatus,
getDoseId,
isDoseTakenAutomatically,
showClearMissedConfirm,
setShowClearMissedConfirm,
clearingMissed,
@@ -218,6 +77,8 @@ export function DashboardPage() {
loadSettings,
} = useAppContext();
useModalHistory(showClearMissedConfirm, "clearMissed", () => setShowClearMissedConfirm(false));
// Get structured reminder data
const reminderData = getReminderStatusData(
settings.reminderDaysBefore,
@@ -907,6 +768,8 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
@@ -926,6 +789,14 @@ export function DashboardPage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
@@ -1153,6 +1024,8 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
@@ -1172,6 +1045,14 @@ export function DashboardPage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
@@ -1362,6 +1243,8 @@ export function DashboardPage() {
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
@@ -1381,6 +1264,14 @@ export function DashboardPage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</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 { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useUnsavedChangesWarning } from "../hooks";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, Medication } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
@@ -125,6 +125,7 @@ export function MedicationsPage() {
const [showObsolete, setShowObsolete] = useState(true);
const [readOnlyView, setReadOnlyView] = useState(false);
const [showReportModal, setShowReportModal] = useState(false);
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
const [showNameValidation, setShowNameValidation] = useState(false);
useEffect(() => {
@@ -463,6 +464,20 @@ export function MedicationsPage() {
// Reset form after successful save
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();
setViewMode("grid");
} else {
@@ -480,6 +495,8 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes
useEffect(() => {
const handlePopState = () => {
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
// Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) {
setShowObsoleteConfirm(false);
@@ -497,6 +514,11 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
if (currentEditMedId) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
processedEditMedIdRef.current = currentEditMedId;
clearEditMedIdParam();
}
if (showEditModal) {
setShowEditModal(false);
resetForm();
@@ -515,6 +537,10 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
if (currentEditMedId) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
processedEditMedIdRef.current = currentEditMedId;
}
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
@@ -562,37 +588,12 @@ export function MedicationsPage() {
return () => document.removeEventListener("keydown", handleEscape);
}, [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) {
const normalizedMed = normalizeMedicationForEdit(med);
if (formChanged) {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(false);
startEdit(normalizedMed, openEditModal);
startEdit(med, openEditModal);
setViewMode("form");
};
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
@@ -602,17 +603,16 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
startEdit(normalizedMed, openEditModal);
startEdit(med, openEditModal);
setViewMode("form");
}
function handleViewClick(med: Medication) {
const normalizedMed = normalizeMedicationForEdit(med);
if (formChanged) {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(true);
startEdit(normalizedMed, openEditModal);
startEdit(med, openEditModal);
setViewMode("form");
};
setUnsavedConfirmSource(showEditModal ? "mobile-edit" : "desktop-form");
@@ -622,7 +622,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(true);
setActiveTab("general");
startEdit(normalizedMed, openEditModal);
startEdit(med, openEditModal);
setViewMode("form");
}
@@ -685,13 +685,13 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
startEdit(normalizeMedicationForEdit(medicationToEdit), openEditModal);
startEdit(medicationToEdit, openEditModal);
setViewMode("form");
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
setSearchParams(nextParams, { replace: true });
}, [allMeds, normalizeMedicationForEdit, openEditModal, searchParams, setSearchParams, startEdit]);
}, [allMeds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => {
if (!editingId) return null;
+21 -2
View File
@@ -1,3 +1,4 @@
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
@@ -65,6 +66,7 @@ export function SchedulePage() {
pastDays,
futureDays,
takenDoses,
isDoseTakenAutomatically,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
@@ -204,13 +206,15 @@ export function SchedulePage() {
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
<Bell size={14} aria-hidden="true" />
</span>
)}{" "}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
return (
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
{person && (
@@ -230,6 +234,14 @@ export function SchedulePage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span
className="info-tooltip"
data-tooltip={t("tooltips.automaticTaken")}
>
🤖
</span>
)}
</button>
) : (
@@ -365,13 +377,15 @@ export function SchedulePage() {
className="reminder-icon info-tooltip"
data-tooltip={t("tooltips.intakeReminders")}
>
🔔
<Bell size={14} aria-hidden="true" />
</span>
)}
<div className="dose-checks">
{people.map((person) => {
const doseId = getDoseId(dose.id, person);
const isTaken = takenDoses.has(doseId);
const isAutomaticallyTaken =
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
const isOverdue = !isTaken && dose.when < now && !isPastDay;
return (
<div
@@ -395,6 +409,11 @@ export function SchedulePage() {
onClick={() => undoDoseTaken(doseId)}
title={t("common.undo")}
>
{isAutomaticallyTaken && (
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
🤖
</span>
)}
</button>
) : (
+6 -16
View File
@@ -89,7 +89,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailStockReminders : false}
checked={settings.emailStockReminders}
onChange={(e) => setSettings({ ...settings, emailStockReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -100,9 +100,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrStockReminders : false
}
checked={settings.shoutrrrStockReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrStockReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -116,7 +114,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={settings.smtpHost && settings.emailEnabled ? settings.emailIntakeReminders : false}
checked={settings.emailIntakeReminders}
onChange={(e) => setSettings({ ...settings, emailIntakeReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -127,9 +125,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled ? settings.shoutrrrIntakeReminders : false
}
checked={settings.shoutrrrIntakeReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrIntakeReminders: e.target.checked })}
disabled={!settings.shoutrrrEnabled}
/>
@@ -143,9 +139,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.emailEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.smtpHost && settings.emailEnabled ? settings.emailPrescriptionReminders : false
}
checked={settings.emailPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, emailPrescriptionReminders: e.target.checked })}
disabled={!settings.emailEnabled}
/>
@@ -156,11 +150,7 @@ export function SettingsPage() {
<label className={`toggle-switch small${!settings.shoutrrrEnabled ? " disabled" : ""}`}>
<input
type="checkbox"
checked={
settings.shoutrrrUrl && settings.shoutrrrEnabled
? settings.shoutrrrPrescriptionReminders
: false
}
checked={settings.shoutrrrPrescriptionReminders}
onChange={(e) => setSettings({ ...settings, shoutrrrPrescriptionReminders: e.target.checked })}
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 */
/* Medication Detail Modal */
.med-detail-modal {
.modal-content.med-detail-modal {
padding: 0;
width: min(100vw - 1rem, 520px);
width: min(100vw - 1rem, 711px);
max-width: 711px;
max-height: 90vh;
background: var(--bg-primary);
overscroll-behavior: contain;
@@ -4668,6 +4669,22 @@ button.has-validation-error {
.med-schedule-time {
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 {
@@ -4684,12 +4701,12 @@ button.has-validation-error {
position: relative;
z-index: 1;
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).
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 {
padding: 0.4rem;
align-items: stretch;
@@ -4912,7 +4929,7 @@ button.has-validation-error {
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0 -1.5rem;
margin: 0;
}
.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. */
@media (max-width: 900px) {
@media (max-width: 500px) {
.modal-overlay.med-detail-overlay {
padding: 0 !important;
align-items: stretch;
@@ -4969,9 +4986,8 @@ button.has-validation-error {
margin: 0;
padding-left: 1rem;
padding-right: 1rem;
position: sticky;
bottom: 0;
z-index: 5;
position: relative;
z-index: 1;
}
}
+13
View File
@@ -41,6 +41,19 @@
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 {
from {
opacity: 0;
+96 -7
View File
@@ -190,17 +190,27 @@
}
/* Mobile Edit Modal */
.mobile-edit-overlay {
align-items: flex-start;
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.edit-modal {
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
padding: 0.75rem;
max-height: none;
height: min(96dvh, 96vh);
padding: 0.6rem;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-x: hidden;
}
.edit-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
}
@@ -208,12 +218,91 @@
.edit-modal-header h2 {
font-size: 1.25rem;
margin: 0;
text-align: left;
min-width: 0;
}
.mobile-edit-form.form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem 1rem;
display: flex;
flex-direction: column;
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 {
+1 -1
View File
@@ -556,7 +556,7 @@ describe("UserProfile", () => {
);
await waitFor(() => {
const cancelBtn = screen.getByText(/common\.cancel/i);
const cancelBtn = screen.getByText(/common\.close/i);
fireEvent.click(cancelBtn);
});
@@ -70,12 +70,12 @@ describe("ExportModal", () => {
it("renders cancel button", () => {
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", () => {
render(<ExportModal {...defaultProps} />);
fireEvent.click(screen.getByText(/exportImport\.cancelButton/i));
fireEvent.click(screen.getByText(/common\.close/i));
expect(defaultProps.onClose).toHaveBeenCalled();
});
@@ -216,6 +216,32 @@ describe("MedDetailModal", () => {
const body = document.querySelector(".med-detail-body");
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", () => {
@@ -744,7 +770,7 @@ describe("MedDetailModal stock overflow warning", () => {
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 = {
name: "Test Med",
medsLeft: 49,
@@ -756,9 +782,9 @@ describe("MedDetailModal stock overflow warning", () => {
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");
expect(warningIcon).not.toBeInTheDocument();
expect(warningIcon).toBeInTheDocument();
});
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()]} />);
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);
});
@@ -54,7 +54,8 @@ describe("ShareDialog", () => {
it("calls onClose when close button is clicked", () => {
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();
});
+4 -3
View File
@@ -245,8 +245,8 @@ describe("useSettings", () => {
await result.current.saveSettings(mockEvent);
});
// emailEnabled should be false in the saved state
expect(result.current.settings.emailEnabled).toBe(false);
// Local state preserves user choice; backend receives effective value via payload
expect(result.current.settings.emailEnabled).toBe(true);
});
it("auto-disables shoutrrr when URL is empty", async () => {
@@ -274,7 +274,8 @@ describe("useSettings", () => {
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 () => {
@@ -1,15 +1,15 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "../../pages/DashboardPage";
import {
DashboardPage,
formatFullBlisters,
formatOpenBlisterAndLoose,
getBlisterStock,
getMedTotal,
getReminderStatusData,
userStorageKey,
} from "../../pages/DashboardPage";
} from "../../pages/dashboard-helpers";
// Mock data for tests with medications
const mockMeds = [
@@ -181,6 +181,7 @@ const createMockAppContext = (overrides = {}) => ({
missedPastDoseIds: [],
getDayStockStatus: vi.fn(() => "success"),
getDoseId: vi.fn((id, person) => (person ? `${id}-${person}` : id)),
isDoseTakenAutomatically: vi.fn(() => false),
showClearMissedConfirm: false,
setShowClearMissedConfirm: vi.fn(),
clearingMissed: false,
@@ -198,7 +199,7 @@ describe("DashboardPage helper functions", () => {
});
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", () => {
@@ -206,7 +207,7 @@ describe("DashboardPage helper functions", () => {
expect(formatFullBlisters(1, t)).toBe("1 common.blister");
expect(formatFullBlisters(3, t)).toBe("3 common.blisters");
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", () => {
@@ -124,6 +124,7 @@ const fetchMock = vi.fn();
vi.mock("../../hooks", () => ({
useMedicationForm: () => mockFormHookValue,
useUnsavedChangesWarning: () => ({}),
useModalHistory: vi.fn(),
}));
vi.mock("../../context", () => ({
@@ -111,6 +111,7 @@ const createMockContext = (overrides = {}) => ({
manuallyExpandedDays: new Set(),
toggleDayCollapse: vi.fn(),
openUserFilter: vi.fn(),
isDoseTakenAutomatically: vi.fn(() => false),
missedPastDoseIds: [],
...overrides,
});
+1 -1
View File
@@ -201,7 +201,7 @@ describe("getBlisterStock", () => {
const result = getBlisterStock(med);
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);
});
+1
View File
@@ -69,6 +69,7 @@ export type PlannerRow = {
medicationId: number;
medicationName: string;
totalPills: number;
currentPills?: number;
plannerUsage: number;
blisterSize: number;
blistersNeeded: number;
+3 -6
View File
@@ -3,6 +3,8 @@
// =============================================================================
import type { BlisterStock, Medication } from "../types";
import { getMedTotal } from "../types";
import { splitCurrentBlisterStock } from "./stock";
/**
* 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
*/
export function getBlisterStock(med: Medication): BlisterStock {
const total =
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 };
return splitCurrentBlisterStock(getMedTotal(med), med.pillsPerBlister, med.looseTablets);
}
/**
+1
View File
@@ -5,4 +5,5 @@
export * from "./formatters";
export * from "./ics";
export * from "./schedule";
export * from "./stock";
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);
}