feat: theme dropdown with system preference and comprehensive bottle-type fixes (#138)

- Replace dark/light toggle with Light/Dark/System dropdown menu
- System theme follows OS prefers-color-scheme setting
- Apply theme dropdown to shared schedule page
- Fix 7 packageType (bottle) bugs across stock calc, share, refills, export/import
- Fix planner bottle-type stock calculation and display
- Fix dailyRate double-counting with per-intake takenBy
- Fix About modal update check stale caching
- Fix intake reminder past-intake seeding and push title
- Fix phantom DB path in drizzle.config.ts
- Fix mobile dose field visibility
- Make medication name clickable in dashboard reminder bar
- Improve planner checkbox UX with inline tooltip
- Add 20+ new tests covering all fixes
This commit is contained in:
Daniel Volz
2026-02-08 20:32:40 +01:00
committed by GitHub
parent b19bcf02c2
commit 8c5deed4c2
29 changed files with 1053 additions and 166 deletions
+1 -1
View File
@@ -5,6 +5,6 @@ export default defineConfig({
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "./data/medassist.db",
url: process.env.DATABASE_URL || "./data/medassist-ng.db",
},
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
"version": "1.8.3",
"version": "1.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.8.3",
"version": "1.8.8",
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
+2 -2
View File
@@ -156,7 +156,7 @@ const translations: Record<Language, TranslationKeys> = {
push: {
stockTitle: "MedAssist-ng: 1 Medication Running Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low",
intakeTitle: "💊 Medication Reminder in {minutes} min",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
@@ -210,7 +210,7 @@ const translations: Record<Language, TranslationKeys> = {
push: {
stockTitle: "MedAssist-ng: 1 Medikament wird knapp",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp",
intakeTitle: "💊 Einnahme-Erinnerung in {minutes} Min.",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
+3
View File
@@ -37,6 +37,7 @@ const inventorySchema = z.object({
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"),
});
const medicationExportSchema = z.object({
@@ -276,6 +277,7 @@ export async function exportRoutes(app: FastifyInstance) {
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
@@ -490,6 +492,7 @@ export async function exportRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName || null,
takenByJson,
packageType: med.inventory.packageType ?? "blister",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
+28 -12
View File
@@ -656,7 +656,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const blistersPerPack = row.blistersPerPack ?? 1;
const looseTablets = row.looseTablets ?? 0;
const stockAdjustment = row.stockAdjustment ?? 0;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
const packageType = row.packageType ?? "blister";
// For bottle type, looseTablets IS the current stock (no blister math)
const originalTotalPills =
packageType === "bottle"
? 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
@@ -735,18 +741,27 @@ export async function medicationRoutes(app: FastifyInstance) {
// Calculate AVAILABLE = stock AFTER the planned period (currentStock - usageTotal)
const availableAfterPeriod = Math.max(0, currentStock - usageTotal);
// Calculate stock breakdown for availableAfterPeriod
// Consumption order: loose pills first, then from blisters
const totalConsumedByEnd = originalTotalPills - availableAfterPeriod;
const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets);
const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd);
const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd;
const originalBlisterPills = originalTotalPills - looseTablets;
const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed);
let fullBlisters: number;
let loosePills: number;
const fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0;
const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0;
const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose
if (packageType === "bottle") {
// Bottle type: no blisters, everything is loose pills
fullBlisters = 0;
loosePills = availableAfterPeriod;
} else {
// Blister type: calculate stock breakdown
// Consumption order: loose pills first, then from blisters
const totalConsumedByEnd = originalTotalPills - availableAfterPeriod;
const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets);
const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd);
const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd;
const originalBlisterPills = originalTotalPills - looseTablets;
const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed);
fullBlisters = pillsPerBlister > 0 ? Math.floor(blisterPillsRemaining / pillsPerBlister) : 0;
const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0;
loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose
}
const enough = currentStock >= usageTotal;
return {
@@ -759,6 +774,7 @@ export async function medicationRoutes(app: FastifyInstance) {
fullBlisters,
loosePills,
enough,
packageType,
};
});
+11 -6
View File
@@ -78,9 +78,13 @@ export async function refillRoutes(app: FastifyInstance) {
})
.returning();
// Calculate pills added for response
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
// Calculate pills added for response (packageType-aware)
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
const newTotalPills = isBottle
? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
return {
success: true,
@@ -94,7 +98,7 @@ export async function refillRoutes(app: FastifyInstance) {
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newPackCount * pillsPerPack + newLooseTablets,
totalPills: newTotalPills,
},
};
});
@@ -120,13 +124,14 @@ export async function refillRoutes(app: FastifyInstance) {
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
const pillsPerPack = med.blistersPerPack * med.pillsPerBlister;
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: r.packsAdded * pillsPerPack + r.loosePillsAdded,
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
refillDate: r.refillDate,
}));
});
+4 -1
View File
@@ -114,7 +114,9 @@ export async function shareRoutes(app: FastifyInstance) {
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills =
med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
(med.packageType ?? "blister") === "bottle"
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
@@ -123,6 +125,7 @@ export async function shareRoutes(app: FastifyInstance) {
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: med.packageType ?? "blister",
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
@@ -409,10 +409,18 @@ async function checkAndSendIntakeRemindersForUser(
if (!existingEntry) {
// New dose - send first reminder
if (isIntakePast) {
// Already missed - this is first nagging reminder (count=1)
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
// Intake time already passed and we have no state entry — this means the scheduler
// was not aware of this intake before it happened (e.g., user just enabled reminders).
// Seed the state as already handled so repeat reminders can track from here,
// but do NOT send a notification for intakes that were missed before tracking started.
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 0,
advanceSent: false,
};
logger.info(
`[IntakeReminder] User ${settings.userId}: First nagging for missed "${intake.medName}" at ${intake.intakeTimeStr} (1/${maxReminders})`
`[IntakeReminder] User ${settings.userId}: Seeding state for past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — first detection)`
);
} else {
// Upcoming - this is advance reminder (no counter)
@@ -551,7 +559,10 @@ async function checkAndSendIntakeRemindersForUser(
if (hasNaggingReminder && highestSendCount > 0) {
// Nagging reminder - show counter
const counterStr = `(${highestSendCount}/${maxReminderCount})`;
title = language === "de" ? `⚠️ Medikamenten-Erinnerung ${counterStr}` : `⚠️ Medication Reminder ${counterStr}`;
title =
language === "de"
? `⚠️ Erinnerung: Medikamenteneinnahme ${counterStr}`
: `⚠️ Reminder: Medication intake ${counterStr}`;
} else {
// Advance reminder - no counter
title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
+3 -1
View File
@@ -106,7 +106,9 @@ async function getMedicationsNeedingReminder(
for (const row of rows) {
const blisters = parseBlistersFromRow(row);
const totalPills =
row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
(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);
// Check if medication runs out within reminderDaysBefore days
+246
View File
@@ -2214,4 +2214,250 @@ describe("E2E Tests with Real Routes", () => {
expect(medsResponse.json()[0].packCount).toBe(10);
});
});
// ---------------------------------------------------------------------------
// Package Type (bottle vs blister) Tests
// ---------------------------------------------------------------------------
describe("Package type handling (bottle vs blister)", () => {
const bottleMedication = {
name: "Vitamin D Drops",
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 120,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const blisterMedication = {
name: "Aspirin Blister",
packageType: "blister",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
it("should create and return bottle type medication", async () => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload: bottleMedication,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.packageType).toBe("bottle");
expect(data.looseTablets).toBe(120);
});
it("should return packageType in shared schedule for bottle type", async () => {
// Create bottle medication with takenBy
await app.inject({
method: "POST",
url: "/medications",
payload: { ...bottleMedication, takenBy: ["Daniel"] },
});
// Create share token
const shareResponse = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareResponse.statusCode).toBe(200);
const { token } = shareResponse.json();
// Get shared schedule
const scheduleResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleResponse.statusCode).toBe(200);
const data = scheduleResponse.json();
expect(data.medications).toHaveLength(1);
expect(data.medications[0].packageType).toBe("bottle");
// Bottle totalPills = looseTablets + stockAdjustment (no blister math)
expect(data.medications[0].totalPills).toBe(120);
});
it("should calculate correct totalPills for shared blister medication", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: { ...blisterMedication, takenBy: ["Daniel"] },
});
const shareResponse = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
const { token } = shareResponse.json();
const scheduleResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleResponse.statusCode).toBe(200);
const data = scheduleResponse.json();
expect(data.medications).toHaveLength(1);
expect(data.medications[0].packageType).toBe("blister");
// Blister totalPills = 2 * 3 * 10 + 5 = 65
expect(data.medications[0].totalPills).toBe(65);
});
it("should calculate correct refill totalPillsAdded for bottle type", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: bottleMedication,
});
const medId = createResponse.json().id;
// Refill bottle: only loosePillsAdded matters, packs should add 0 pills
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 30 },
});
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.refill.totalPillsAdded).toBe(30);
// newStock.totalPills should be looseTablets only (no blister math)
expect(data.newStock.totalPills).toBe(150); // 120 + 30
});
it("should calculate correct refill totalPillsAdded for blister type", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: blisterMedication,
});
const medId = createResponse.json().id;
// Refill blister: 1 pack = 3 blisters * 10 pills = 30 pills + 5 loose
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 5 },
});
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
});
it("should return correct totalPillsAdded in refill history for bottle type", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: bottleMedication,
});
const medId = createResponse.json().id;
// Add refill
await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 25 },
});
// Get refill history
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
const refills = historyResponse.json();
expect(refills).toHaveLength(1);
// For bottle type, totalPillsAdded = loosePillsAdded only
expect(refills[0].totalPillsAdded).toBe(25);
});
it("should export and import bottle type medication correctly", async () => {
// Create bottle medication
await app.inject({
method: "POST",
url: "/medications",
payload: bottleMedication,
});
// Export
const exportResponse = await app.inject({
method: "GET",
url: "/export",
});
expect(exportResponse.statusCode).toBe(200);
const exportData = exportResponse.json();
expect(exportData.medications).toHaveLength(1);
expect(exportData.medications[0].inventory.packageType).toBe("bottle");
expect(exportData.medications[0].inventory.looseTablets).toBe(120);
// Clear and re-import
await clearData(testClient);
await testClient.execute(
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
);
const importResponse = await app.inject({
method: "POST",
url: "/import",
payload: exportData,
});
expect(importResponse.statusCode).toBe(200);
expect(importResponse.json().success).toBe(true);
// Verify imported medication has correct packageType
const medsResponse = await app.inject({
method: "GET",
url: "/medications",
});
expect(medsResponse.json()).toHaveLength(1);
const med = medsResponse.json()[0];
expect(med.name).toBe("Vitamin D Drops");
expect(med.packageType).toBe("bottle");
expect(med.looseTablets).toBe(120);
});
it("should default to blister when importing without packageType", async () => {
const importData = {
version: "1.0",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Old Export Med",
inventory: { packCount: 2, blistersPerPack: 3, pillsPerBlister: 10, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
],
};
const importResponse = await app.inject({
method: "POST",
url: "/import",
payload: importData,
});
expect(importResponse.statusCode).toBe(200);
const medsResponse = await app.inject({
method: "GET",
url: "/medications",
});
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].packageType).toBe("blister");
});
});
});