diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 5c85e07..ba96ab4 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -1,5 +1,4 @@ import { existsSync, statSync } from "node:fs"; -import { resolve } from "node:path"; import { type Client, createClient } from "@libsql/client"; import dotenv from "dotenv"; import { drizzle } from "drizzle-orm/libsql"; @@ -8,7 +7,6 @@ import { log } from "../utils/logger.js"; import { ensureDataDirectory, ensureDefaultUser, - getDataDir, getDbPaths, repairOrphanedDoseIds, repairTrailingHyphenDoseIds, @@ -65,8 +63,8 @@ let client: Client; try { client = createClient({ url }); log.debug(`[DB] Database client created successfully`); -} catch (err: any) { - log.error(`[DB] ERROR: Failed to create database client: ${err.message}`); +} catch (err: unknown) { + log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`); log.error(`[DB] Database path: ${dbPath}`); process.exit(1); } diff --git a/backend/src/db/db-utils.ts b/backend/src/db/db-utils.ts index 14bd89e..df1b43e 100644 --- a/backend/src/db/db-utils.ts +++ b/backend/src/db/db-utils.ts @@ -71,8 +71,8 @@ export function ensureDataDirectory(dataDir: string): { success: boolean; error? writeFileSync(testFile, "test"); return { success: true }; - } catch (err: any) { - return { success: false, error: err.message }; + } catch (err: unknown) { + return { success: false, error: (err as Error).message }; } } @@ -87,14 +87,14 @@ export async function runDrizzleMigrations( try { await migrate(database, { migrationsFolder }); return { success: true }; - } catch (err: any) { + } catch (err: unknown) { // If the error is about existing schema objects, the DB is already up-to-date // This happens when ALTER migrations in client.ts have already added the columns, // or when tables were created before drizzle migrations were introduced - if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) { - return { success: true, warning: `Schema already up-to-date: ${err.message}` }; + if ((err as Error).message?.includes("duplicate column") || (err as Error).message?.includes("already exists")) { + return { success: true, warning: `Schema already up-to-date: ${(err as Error).message}` }; } - return { success: false, error: err.message }; + return { success: false, error: (err as Error).message }; } } @@ -158,10 +158,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo for (const sql of alterMigrations) { try { await client.execute(sql); - } catch (e: any) { + } catch (e: unknown) { // Silently ignore "duplicate column" errors - column already exists - if (!e.message?.includes("duplicate column")) { - errors.push(e.message); + if (!(e as Error).message?.includes("duplicate column")) { + errors.push((e as Error).message); } } } @@ -182,10 +182,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo for (const sql of createTableMigrations) { try { await client.execute(sql); - } catch (e: any) { + } catch (e: unknown) { // Silently ignore "table already exists" errors - if (!e.message?.includes("already exists")) { - errors.push(e.message); + if (!(e as Error).message?.includes("already exists")) { + errors.push((e as Error).message); } } } @@ -199,10 +199,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo for (const sql of createIndexMigrations) { try { await client.execute(sql); - } catch (e: any) { + } catch (e: unknown) { // Silently ignore "already exists" errors - if (!e.message?.includes("already exists")) { - errors.push(e.message); + if (!(e as Error).message?.includes("already exists")) { + errors.push((e as Error).message); } } } @@ -227,8 +227,8 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P return true; // Created } return false; // Already exists - } catch (e: any) { - console.error(`[DB] Error creating default user:`, e.message); + } catch (e: unknown) { + console.error(`[DB] Error creating default user:`, (e as Error).message); return false; } } @@ -255,8 +255,8 @@ export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ rep "UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'" ); repaired = result.rowsAffected; - } catch (e: any) { - errors.push(`Trailing-hyphen repair failed: ${e.message}`); + } catch (e: unknown) { + errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`); } return { repaired, errors }; @@ -379,14 +379,14 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: args: [newDoseId, dose.id], }); repaired++; - } catch (e: any) { - errors.push(`Failed to repair dose ${dose.id}: ${e.message}`); + } catch (e: unknown) { + errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`); } } } } - } catch (e: any) { - errors.push(`Repair failed: ${e.message}`); + } catch (e: unknown) { + errors.push(`Repair failed: ${(e as Error).message}`); } return { repaired, errors }; diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 079026f..40dc725 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -41,8 +41,8 @@ export async function executeMigration( const executed = Number(tables.rows[0].count) || 0; return { success: true, executed, errors }; - } catch (err: any) { - errors.push(err.message); + } catch (err: unknown) { + errors.push((err as Error).message); return { success: false, executed: 0, errors }; } } diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index 21626ae..6784252 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -142,9 +142,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply) id: user.id, username: user.username, }; - } catch (err: any) { + } catch (err: unknown) { // Re-throw our own errors - if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") { + if ( + err instanceof Error && + (err.message === "AUTH_REQUIRED" || err.message === "USER_NOT_FOUND" || err.message === "ACCOUNT_DISABLED") + ) { throw err; } // JWT verification failed diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index d3db999..06eb9ae 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -864,11 +864,8 @@ export async function medicationRoutes(app: FastifyInstance) { const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : []; const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; - const peopleForThisIntake: (string | null)[] = intakePerson - ? [intakePerson] - : takenByJson.length > 0 - ? takenByJson - : [null]; + const takenByFallback: (string | null)[] = takenByJson.length > 0 ? takenByJson : [null]; + const peopleForThisIntake: (string | null)[] = intakePerson ? [intakePerson] : takenByFallback; // Generate expected dose IDs and check if they're taken for (let i = 0; i < occurrences; i++) { diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index 555f4dc..e53529e 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -104,7 +104,7 @@ export async function oidcRoutes(app: FastifyInstance) { }); return reply.redirect(authUrl.href); - } catch (err: any) { + } catch (err: unknown) { console.error("[OIDC] Login error:", err); return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`); } @@ -167,7 +167,10 @@ export async function oidcRoutes(app: FastifyInstance) { // Extract username from configured claim const usernameClaim = env.OIDC_USERNAME_CLAIM; const username = - (userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub; + (userInfo as Record)[usernameClaim] || + userInfo.preferred_username || + userInfo.email || + userInfo.sub; const oidcSubject = userInfo.sub; if (!username || !oidcSubject) { @@ -210,7 +213,7 @@ export async function oidcRoutes(app: FastifyInstance) { // In dev: CORS_ORIGINS contains the frontend URL const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173"; return reply.redirect(`${frontendUrl}/dashboard`); - } catch (err: any) { + } catch (err: unknown) { console.error("[OIDC] Callback error:", err); return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`); } diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 87ef436..6876cf8 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -509,8 +509,10 @@ ${getFooterPlain(language)}`; const buildTableRow = (row: LowStockItem) => { const isEmpty = row.medsLeft <= 0; const isCritical = row.isCritical !== false; - const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; - const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; + const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; + const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; + const nonEmptyBg = isCritical ? "#fff7ed" : "white"; + const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; const safeName = escapeHtml(row.name); const safeMedsLeft = Number(row.medsLeft) || 0; const safeDaysLeft = Number(row.daysLeft) || 0; @@ -586,7 +588,7 @@ ${getFooterPlain(language)}`; // Send push notification if enabled if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) { - const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; try { const pushResult = await sendShoutrrrNotification(notificationSettings.shoutrrrUrl, notificationTitle, message); @@ -603,7 +605,8 @@ ${getFooterPlain(language)}`; // Update the reminder state to record this notification was sent if (results.email || results.push) { - const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; + const singleChannel = results.email ? "email" : "push"; + const channel = results.email && results.push ? "both" : singleChannel; updateReminderSentTime("stock", channel); // Also update user settings in database so frontend can display the info @@ -700,14 +703,15 @@ ${getFooterPlain(language)}`; const bodyText = emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; - const alertText = - emptyRx.length > 0 - ? emptyRx.length === 1 - ? tr.prescriptionReminder.alertEmptySingle - : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) - : lowRx.length === 1 - ? tr.prescriptionReminder.alertLowSingle - : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + const emptyAlert = + emptyRx.length === 1 + ? tr.prescriptionReminder.alertEmptySingle + : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }); + const lowAlert = + lowRx.length === 1 + ? tr.prescriptionReminder.alertLowSingle + : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert; const tableRows = filteredPrescriptionLow .map((item) => { @@ -807,7 +811,7 @@ ${getFooterPlain(language)}`; ); } } - const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; try { const pushResult = await sendShoutrrrNotification(userSettings.shoutrrrUrl, title, message); @@ -823,7 +827,8 @@ ${getFooterPlain(language)}`; } if (results.email || results.push) { - const channel = results.email && results.push ? "both" : results.email ? "email" : "push"; + const singleChannel = results.email ? "email" : "push"; + const channel = results.email && results.push ? "both" : singleChannel; updateReminderSentTime("prescription", channel); await updateUserReminderSentTime(userId, "prescription", channel, medNames); } diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 665a75e..80ea2d4 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm"; -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { userSettings } from "../db/schema.js"; @@ -239,7 +239,7 @@ export async function settingsRoutes(app: FastifyInstance) { // Helper to get user ID from request // Returns anonymous user ID when auth is disabled - async function getUserId(request: any, reply: any): Promise { + async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { return getAnonymousUserId(); @@ -544,7 +544,7 @@ export async function sendShoutrrrNotification( } // Use ONLY the reconstructed URL from validation - never the original urlStr - const { url: sanitizedUrl, isNtfy, auth } = validation; + const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation; let targetUrl: string; const method = "POST"; diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index d46a24b..5dc9fcb 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -22,7 +22,6 @@ import { getTimezone, getTodaysIntakes, getUpcomingIntakes, - type Intake, type IntakeReminderState, parseIntakeReminderState, parseIntakesJson, @@ -321,7 +320,7 @@ async function checkAndSendIntakeRemindersForUser( }); // Process each intake separately to track blisterIndex - intakesWithReminders.forEach((intake, blisterIndex) => { + intakesWithReminders.forEach((intake, _blisterIndex) => { const actualIndex = intakes.indexOf(intake); // Get the actual index in original array logger.debug( `[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}` @@ -684,7 +683,8 @@ async function checkAndSendIntakeRemindersForUser( saveIntakeReminderState(state); // Update global reminder state for UI display - const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + const singleChannel = emailSuccess ? "email" : "push"; + const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel; updateReminderSentTime("intake", channel); // Also update user settings in database so frontend can display the info diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 5047c87..f8fbd3f 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -275,8 +275,10 @@ async function sendReminderEmail( .map((row) => { const isEmpty = row.medsLeft <= 0; const isCritical = row.isCritical; - const statusIcon = isEmpty ? "🚨" : isCritical ? "🚨" : "⚠️"; - const rowBg = isEmpty ? "#fef2f2" : isCritical ? "#fff7ed" : "white"; + const nonEmptyIcon = isCritical ? "🚨" : "⚠️"; + const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; + const nonEmptyBg = isCritical ? "#fff7ed" : "white"; + const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; return ` ${statusIcon} ${row.name} @@ -329,7 +331,8 @@ ${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft --- ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; - const subjectPlural = lowStock.length === 1 ? "" : language === "de" ? "e" : "s"; + const pluralSuffix = language === "de" ? "e" : "s"; + const subjectPlural = lowStock.length === 1 ? "" : pluralSuffix; const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural }); try { @@ -460,7 +463,7 @@ async function checkAndSendReminderForUser( ) ); } - const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { @@ -470,7 +473,8 @@ async function checkAndSendReminderForUser( if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); - const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + const singleChannel = emailSuccess ? "email" : "push"; + const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, @@ -480,7 +484,6 @@ async function checkAndSendReminderForUser( lastNotificationChannel: channel, }); - const firstMed = allLowStock[0]; const medNames = allLowStock.map((m) => m.name).join(", "); await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); } @@ -537,14 +540,15 @@ async function checkAndSendReminderForUser( const bodyText = emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; - const alertText = - emptyRx.length > 0 - ? emptyRx.length === 1 - ? tr.prescriptionReminder.alertEmptySingle - : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }) - : lowRx.length === 1 - ? tr.prescriptionReminder.alertLowSingle - : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + const emptyAlert = + emptyRx.length === 1 + ? tr.prescriptionReminder.alertEmptySingle + : t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length }); + const lowAlert = + lowRx.length === 1 + ? tr.prescriptionReminder.alertLowSingle + : t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length }); + const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert; const tableRows = allPrescriptionLow .map((item) => { @@ -649,7 +653,7 @@ async function checkAndSendReminderForUser( ); } } - const message = messageParts.join("\n") + `\n\n---\n${getFooterPlain(language)}`; + const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`; const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (!result.success) { @@ -659,7 +663,8 @@ async function checkAndSendReminderForUser( if (emailSuccess || shoutrrrSuccess) { const currentState = loadReminderState(); - const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push"; + const singleChannel = emailSuccess ? "email" : "push"; + const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel; saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, @@ -669,7 +674,6 @@ async function checkAndSendReminderForUser( lastNotificationChannel: channel, }); - const firstMed = allPrescriptionLow[0]; const medNames = allPrescriptionLow.map((m) => m.name).join(", "); await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames); } diff --git a/backend/src/test/auth.test.ts b/backend/src/test/auth.test.ts index d51b6c9..9a34615 100644 --- a/backend/src/test/auth.test.ts +++ b/backend/src/test/auth.test.ts @@ -294,8 +294,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { // Should set cookies const cookies = response.cookies; - expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined(); - expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined(); + expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined(); + expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined(); }); it("should login case-insensitively with different username casing", async () => { @@ -393,7 +393,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); + const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token"); const response = await app.inject({ method: "POST", @@ -456,7 +456,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token"); + const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token"); const response = await app.inject({ method: "POST", @@ -506,7 +506,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token"); const response = await app.inject({ method: "GET", @@ -604,7 +604,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token"); const response = await app.inject({ method: "PUT", @@ -653,7 +653,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token"); const response = await app.inject({ method: "PUT", @@ -689,7 +689,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token"); const response = await app.inject({ method: "PUT", @@ -742,7 +742,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => { }, }); - const accessToken = login.cookies.find((c: any) => c.name === "access_token"); + const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token"); // Delete account const response = await app.inject({ diff --git a/backend/src/test/database.test.ts b/backend/src/test/database.test.ts index 9aab9c7..5050b55 100644 --- a/backend/src/test/database.test.ts +++ b/backend/src/test/database.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB) import { diff --git a/backend/src/test/doses.test.ts b/backend/src/test/doses.test.ts index 37f4b7c..3cdc06a 100644 --- a/backend/src/test/doses.test.ts +++ b/backend/src/test/doses.test.ts @@ -271,7 +271,7 @@ describe("Dose Tracking API", () => { expect(response.statusCode).toBe(200); const data = response.json(); expect(data.doses).toHaveLength(2); - expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort()); + expect(data.doses.map((d: { doseId: string }) => d.doseId).sort()).toEqual([doseId1, doseId2].sort()); // Each dose should have a takenAt timestamp for (const dose of data.doses) { expect(dose.takenAt).toBeTypeOf("number"); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 62c9810..8e6c3b7 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -1671,7 +1671,7 @@ describe("E2E Tests with Real Routes", () => { url: "/medications", }); expect(medsResponse.statusCode).toBe(200); - const med = medsResponse.json().find((m: any) => m.id === medId); + const med = medsResponse.json().find((m: Record) => m.id === medId); expect(med.prescriptionRemainingRefills).toBe(1); const historyResponse = await app.inject({ @@ -1809,8 +1809,10 @@ describe("E2E Tests with Real Routes", () => { const refills = response.json(); expect(refills).toHaveLength(2); // Check both refills exist (order may vary) - const hasPackRefill = refills.some((r: any) => r.packsAdded === 1 && r.loosePillsAdded === 0); - const hasLooseRefill = refills.some((r: any) => r.packsAdded === 0 && r.loosePillsAdded === 5); + const hasPackRefill = refills.some((r: Record) => r.packsAdded === 1 && r.loosePillsAdded === 0); + const hasLooseRefill = refills.some( + (r: Record) => r.packsAdded === 0 && r.loosePillsAdded === 5 + ); expect(hasPackRefill).toBe(true); expect(hasLooseRefill).toBe(true); }); @@ -1888,7 +1890,7 @@ describe("E2E Tests with Real Routes", () => { expect(getResponse.statusCode).toBe(200); const meds = getResponse.json(); - const med = meds.find((m: any) => m.id === medId); + const med = meds.find((m: Record) => m.id === medId); expect(med).toBeDefined(); expect(med.stockAdjustment).toBe(-7); expect(med.lastStockCorrectionAt).toBeTruthy(); @@ -1934,7 +1936,7 @@ describe("E2E Tests with Real Routes", () => { method: "GET", url: "/medications", }); - const med = getResponse.json().find((m: any) => m.id === medId); + const med = getResponse.json().find((m: Record) => m.id === medId); expect(med.name).toBe("Renamed Med"); expect(med.stockAdjustment).toBe(-5); }); @@ -2003,7 +2005,7 @@ describe("E2E Tests with Real Routes", () => { // Verify adjustment is set let getMeds = await app.inject({ method: "GET", url: "/medications" }); - let med = getMeds.json().find((m: any) => m.id === medId); + let med = getMeds.json().find((m: Record) => m.id === medId); expect(med.stockAdjustment).toBe(-10); // Edit medication with CHANGED stock fields (packCount 1 → 2) @@ -2022,7 +2024,7 @@ describe("E2E Tests with Real Routes", () => { // stockAdjustment should be reset to 0 getMeds = await app.inject({ method: "GET", url: "/medications" }); - med = getMeds.json().find((m: any) => m.id === medId); + med = getMeds.json().find((m: Record) => m.id === medId); expect(med.stockAdjustment).toBe(0); expect(med.lastStockCorrectionAt).toBeTruthy(); }); @@ -2066,7 +2068,7 @@ describe("E2E Tests with Real Routes", () => { // stockAdjustment should be preserved const getMeds = await app.inject({ method: "GET", url: "/medications" }); - const med = getMeds.json().find((m: any) => m.id === medId); + const med = getMeds.json().find((m: Record) => m.id === medId); expect(med.name).toBe("Renamed Preserve Med"); expect(med.stockAdjustment).toBe(-5); }); @@ -2114,7 +2116,7 @@ describe("E2E Tests with Real Routes", () => { expect(response.statusCode).toBe(200); const data = response.json(); - const med = data.find((m: any) => m.medicationId === medId); + const med = data.find((m: Record) => m.medicationId === medId); expect(med).toBeDefined(); // Total should be very close to 113 (not 112 or lower from phantom consumption) // Allow up to 1 pill of natural consumption (test runs fast, but at most 1 day could pass) diff --git a/backend/src/test/env.test.ts b/backend/src/test/env.test.ts index 134e98d..9a35881 100644 --- a/backend/src/test/env.test.ts +++ b/backend/src/test/env.test.ts @@ -3,7 +3,7 @@ import { z } from "zod"; // Mock process.exit to prevent tests from exiting const mockExit = vi.fn(); -vi.spyOn(process, "exit").mockImplementation(mockExit as any); +vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never); // Re-create the schema from env.ts for testing const EnvSchema = z.object({ diff --git a/backend/src/test/export.test.ts b/backend/src/test/export.test.ts index 36142da..ef8f052 100644 --- a/backend/src/test/export.test.ts +++ b/backend/src/test/export.test.ts @@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) { const userId = 1; // Test user ID // Helper to parse blisters from DB - function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> { - const usage = JSON.parse(row.usage_json || "[]") as number[]; - const every = JSON.parse(row.every_json || "[]") as number[]; - const start = JSON.parse(row.start_json || "[]") as string[]; + function parseBlisters( + row: Record + ): Array<{ usage: number; every: number; start: string; remind: boolean }> { + const usage = JSON.parse((row.usage_json as string) || "[]") as number[]; + const every = JSON.parse((row.every_json as string) || "[]") as number[]; + const start = JSON.parse((row.start_json as string) || "[]") as string[]; const len = Math.min(usage.length, every.length, start.length); return Array.from({ length: len }, (_, i) => ({ usage: usage[i], @@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) { args: [userId], }); - let settings; + let settings: Record | undefined; if (settingsResult.rows.length > 0) { const s = settingsResult.rows[0]; settings = { @@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) { }); // POST /import - app.post<{ Body: any }>("/import", async (request, reply) => { + // biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape + app.post("/import", async (request, reply) => { const importData = request.body as any; // Basic validation @@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) { // Import medications const exportIdToNewId = new Map(); for (const med of importData.medications || []) { - const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage)); - const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every)); - const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start)); + const usageJson = JSON.stringify( + ((med.schedules as Array>) || []).map((s: Record) => s.usage) + ); + const everyJson = JSON.stringify( + ((med.schedules as Array>) || []).map((s: Record) => s.every) + ); + const startJson = JSON.stringify( + ((med.schedules as Array>) || []).map((s: Record) => s.start) + ); const takenByJson = JSON.stringify(med.takenBy || []); const result = await client.execute({ diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index fca62cb..0611dd1 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -1333,8 +1333,8 @@ describe("Integration Tests", () => { url: "/medications", }); const meds = medsRes.json(); - const med1 = meds.find((m: any) => m.id === med1Id); - const med2 = meds.find((m: any) => m.id === med2Id); + const med1 = meds.find((m: Record) => m.id === med1Id); + const med2 = meds.find((m: Record) => m.id === med2Id); expect(med1.dismissedUntil).toBe("2025-01-15"); expect(med2.dismissedUntil).toBe("2025-01-15"); @@ -1376,7 +1376,7 @@ describe("Integration Tests", () => { method: "GET", url: "/medications", }); - const med = medsRes.json().find((m: any) => m.id === medId); + const med = medsRes.json().find((m: Record) => m.id === medId); expect(med.dismissedUntil).toBeNull(); }); @@ -1446,7 +1446,7 @@ describe("Integration Tests", () => { method: "GET", url: "/medications", }); - const med = medsRes.json().find((m: any) => m.id === medId); + const med = medsRes.json().find((m: Record) => m.id === medId); expect(med.dismissedUntil).toBeNull(); }); }); diff --git a/backend/src/test/planner.test.ts b/backend/src/test/planner.test.ts index f957a93..028a285 100644 --- a/backend/src/test/planner.test.ts +++ b/backend/src/test/planner.test.ts @@ -63,7 +63,7 @@ vi.mock("../services/reminder-scheduler.js", () => ({ // Mock sendShoutrrrNotification from settings vi.mock("../routes/settings.js", async (importOriginal) => { - const original = (await importOriginal()) as any; + const original = (await importOriginal()) as Record; return { ...original, sendShoutrrrNotification: mockSendShoutrrr, diff --git a/backend/src/test/server.test.ts b/backend/src/test/server.test.ts index 9ebcc0f..8b37e58 100644 --- a/backend/src/test/server.test.ts +++ b/backend/src/test/server.test.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import sensible from "@fastify/sensible"; -import Fastify from "fastify"; +import Fastify, { type FastifyInstance } from "fastify"; import { afterEach, describe, expect, it } from "vitest"; // Import from utils to avoid index.ts import side effects (server start) @@ -294,10 +294,18 @@ describe("Server Bootstrap", () => { refreshCookieOptions, }); - expect((app as any).config.accessTtl).toBe(15); - expect((app as any).config.refreshTtl).toBe(7); - expect((app as any).config.cookieOptions.httpOnly).toBe(true); - expect((app as any).config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60); + const appWithConfig = app as unknown as { + config: { + accessTtl: number; + refreshTtl: number; + cookieOptions: { httpOnly: boolean }; + refreshCookieOptions: { maxAge: number }; + }; + }; + expect(appWithConfig.config.accessTtl).toBe(15); + expect(appWithConfig.config.refreshTtl).toBe(7); + expect(appWithConfig.config.cookieOptions.httpOnly).toBe(true); + expect(appWithConfig.config.refreshCookieOptions.maxAge).toBe(7 * 24 * 60 * 60); await app.close(); }); @@ -364,15 +372,15 @@ describe("Server Bootstrap", () => { const app = Fastify({ logger: false }); // Mock route plugins - const healthRoutes = async (app: any) => { + const healthRoutes = async (app: FastifyInstance) => { app.get("/health", async () => ({ status: "ok" })); }; - const authRoutes = async (app: any) => { + const authRoutes = async (app: FastifyInstance) => { app.post("/auth/login", async () => ({ token: "mock" })); }; - const medicationRoutes = async (app: any) => { + const medicationRoutes = async (app: FastifyInstance) => { app.get("/medications", async () => []); }; diff --git a/backend/src/test/stock-calculation.test.ts b/backend/src/test/stock-calculation.test.ts index aea8da4..4061cba 100644 --- a/backend/src/test/stock-calculation.test.ts +++ b/backend/src/test/stock-calculation.test.ts @@ -612,8 +612,8 @@ describe("Stock Calculation API", () => { const data = response.json(); expect(data).toHaveLength(2); - const medA = data.find((d: any) => d.medicationName === "Med A"); - const medB = data.find((d: any) => d.medicationName === "Med B"); + const medA = data.find((d: Record) => d.medicationName === "Med A"); + const medB = data.find((d: Record) => d.medicationName === "Med B"); expect(medA.plannerUsage).toBe(10); // 10 days × 1 pill expect(medB.plannerUsage).toBe(10); // 5 doses × 2 pills diff --git a/backend/src/utils/scheduler-utils.ts b/backend/src/utils/scheduler-utils.ts index f14d6bb..75bb5c2 100644 --- a/backend/src/utils/scheduler-utils.ts +++ b/backend/src/utils/scheduler-utils.ts @@ -191,7 +191,7 @@ export function parseIntakesJson( try { const parsed = JSON.parse(intakesJson); if (Array.isArray(parsed) && parsed.length > 0) { - return parsed.map((intake: any) => ({ + return parsed.map((intake: Record) => ({ usage: typeof intake.usage === "number" ? intake.usage : 0, every: typeof intake.every === "number" ? intake.every : 1, start: typeof intake.start === "string" ? intake.start : new Date().toISOString(), @@ -312,7 +312,7 @@ export type UpcomingIntake = { export function getTodaysIntakes( medName: string, intakes: Intake[], - medicationTakenBy: string[], // Medication-level takenBy as fallback + _medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, tz?: string, @@ -388,7 +388,7 @@ export function getUpcomingIntakes( medName: string, intakes: Intake[], minutesBefore: number, - medicationTakenBy: string[], // Medication-level takenBy as fallback + _medicationTakenBy: string[], // Medication-level takenBy as fallback pillWeightMg: number | null, locale: string, tz?: string, diff --git a/biome.json b/biome.json index 2c46cdf..b0dc752 100644 --- a/biome.json +++ b/biome.json @@ -16,7 +16,8 @@ "rules": { "recommended": true, "complexity": { - "noForEach": "off" + "noForEach": "off", + "noImportantStyles": "off" }, "suspicious": { "noExplicitAny": "warn", diff --git a/frontend/e2e/dashboard-data.spec.ts b/frontend/e2e/dashboard-data.spec.ts index 6ace535..d52dbad 100644 --- a/frontend/e2e/dashboard-data.spec.ts +++ b/frontend/e2e/dashboard-data.spec.ts @@ -2,7 +2,6 @@ import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, - deleteMedicationViaAPI, expect, navigateTo, type TestMedication, diff --git a/frontend/e2e/medications.spec.ts b/frontend/e2e/medications.spec.ts index 7c09932..c46f3dd 100644 --- a/frontend/e2e/medications.spec.ts +++ b/frontend/e2e/medications.spec.ts @@ -71,7 +71,7 @@ test.describe("Medications Page", () => { // Either blister or bottle fields depending on package type const blistersField = page.getByLabel(/Blisters per pack/i); - const pillsField = page.getByLabel(/Pills per blister/i); + const _pillsField = page.getByLabel(/Pills per blister/i); const capacityField = page.getByLabel(/Total Capacity/i); const hasBlister = await blistersField.isVisible().catch(() => false); diff --git a/frontend/e2e/planner-data.spec.ts b/frontend/e2e/planner-data.spec.ts index cd2f409..9f90f0a 100644 --- a/frontend/e2e/planner-data.spec.ts +++ b/frontend/e2e/planner-data.spec.ts @@ -3,7 +3,6 @@ import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, - deleteMedicationViaAPI, expect, navigateTo, type TestMedication, diff --git a/frontend/e2e/schedule-data.spec.ts b/frontend/e2e/schedule-data.spec.ts index 2ae8f20..7eda8cc 100644 --- a/frontend/e2e/schedule-data.spec.ts +++ b/frontend/e2e/schedule-data.spec.ts @@ -2,7 +2,6 @@ import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, - deleteMedicationViaAPI, expect, navigateTo, type TestMedication, diff --git a/frontend/e2e/share-schedule.spec.ts b/frontend/e2e/share-schedule.spec.ts index 1cd35a7..d3eb09c 100644 --- a/frontend/e2e/share-schedule.spec.ts +++ b/frontend/e2e/share-schedule.spec.ts @@ -160,7 +160,7 @@ test.describe("Share Schedule", () => { // Should show the shared schedule page (not the login page) // Wait for either the schedule content or an error - const sharedContent = page.locator(".shared-schedule, .share-page"); + const _sharedContent = page.locator(".shared-schedule, .share-page"); const dayBlock = page.locator(".day-block"); const medName = page.getByText(MED_ALICE); diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx index df6592a..131e0c9 100644 --- a/frontend/src/components/AboutModal.tsx +++ b/frontend/src/components/AboutModal.tsx @@ -51,8 +51,18 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) { if (!isOpen) return null; return ( -
-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index fe06748..cc7a213 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { useUnsavedChanges } from "../context"; -import type { ThemePreference } from "../hooks"; import { useTheme } from "../hooks"; import { useAuth } from "./Auth"; diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index 84a7fb0..2a4f9c2 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -39,8 +39,19 @@ export function ConfirmModal({ }, [onCancel]); return ( -
-
e.stopPropagation()} style={{ maxWidth: "450px" }}> +
{ + if (e.key === "Escape") onCancel(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ maxWidth: "450px" }} + > diff --git a/frontend/src/components/DateInput.tsx b/frontend/src/components/DateInput.tsx index 6c95a0f..e2cf302 100644 --- a/frontend/src/components/DateInput.tsx +++ b/frontend/src/components/DateInput.tsx @@ -28,7 +28,13 @@ export function DateInput({ value, placeholder, className, ...rest }: DateInputP }, []); return ( -
+
{ + if (e.key === "Enter" || e.key === " ") handleClick(); + }} + > diff --git a/frontend/src/components/DateTimeInput.tsx b/frontend/src/components/DateTimeInput.tsx index 337ef54..5b7d181 100644 --- a/frontend/src/components/DateTimeInput.tsx +++ b/frontend/src/components/DateTimeInput.tsx @@ -29,7 +29,13 @@ export function DateTimeInput({ value, placeholder, className, ...rest }: DateTi }, []); return ( -
+
{ + if (e.key === "Enter" || e.key === " ") handleClick(); + }} + > diff --git a/frontend/src/components/ExportModal.tsx b/frontend/src/components/ExportModal.tsx index 1c8e06e..1942cf6 100644 --- a/frontend/src/components/ExportModal.tsx +++ b/frontend/src/components/ExportModal.tsx @@ -13,8 +13,19 @@ export default function ExportModal({ isOpen, onClose, onExport, exporting }: Ex if (!isOpen) return null; return ( -
-
e.stopPropagation()} style={{ maxWidth: "450px" }}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + style={{ maxWidth: "450px" }} + > diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx index 5bc44a6..050da53 100644 --- a/frontend/src/components/Lightbox.tsx +++ b/frontend/src/components/Lightbox.tsx @@ -19,12 +19,24 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) { } return ( -
+
{ + if (e.key === "Escape") onClose(); + }} + >
- {alt} e.stopPropagation()} /> + {alt} e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + />
); diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 1fa5c82..679991a 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -154,14 +154,24 @@ export function MedDetailModal({ const packageSize = getPackageSize(selectedMed); const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed); const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const textClass = - status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text"; + const fallbackTextClass = status?.className === "warning" ? "warning-text" : "success-text"; + const textClass = status?.className === "danger" ? "danger-text" : fallbackTextClass; const stock = getBlisterStock(currentStock, selectedMed.pillsPerBlister, selectedMed.looseTablets, packageSize); const fullForBounds = Math.max(0, parseStockInput(editStockFullInput)); return ( -
-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > @@ -172,6 +182,11 @@ export function MedDetailModal({
selectedMed.imageUrl && onOpenImageLightbox()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (selectedMed.imageUrl) onOpenImageLightbox(); + } + }} > {selectedMed.imageUrl && 🔍} @@ -408,6 +423,9 @@ export function MedDetailModal({

onRefillHistoryExpandedChange(!refillHistoryExpanded)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") onRefillHistoryExpandedChange(!refillHistoryExpanded); + }} > {t("refill.history")} ({refillHistory.length}) {refillHistoryExpanded ? "▼" : "▶"} @@ -488,8 +506,16 @@ export function MedDetailModal({ e.stopPropagation(); onCloseRefillModal(); }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") onCloseRefillModal(); + }} > -
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > @@ -585,8 +611,16 @@ export function MedDetailModal({ e.stopPropagation(); onCloseEditStockModal(); }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") onCloseEditStockModal(); + }} > -
e.stopPropagation()}> +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > @@ -602,6 +636,8 @@ export function MedDetailModal({ ? editStockPartialBlisterPills : editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills; const difference = newTotal - currentTotal; + const negativeFallback = difference < 0 ? "negative" : ""; + const differenceClass = difference > 0 ? "positive" : negativeFallback; return ( <> @@ -691,9 +727,7 @@ export function MedDetailModal({ {newTotal} {newTotal === 1 ? t("common.pill") : t("common.pills")}
-
0 ? "positive" : difference < 0 ? "negative" : ""}`} - > +
{t("editStock.difference")}: {difference > 0 ? "+" : ""} diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 10dde3a..4fe21f4 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -137,13 +137,28 @@ export function MobileEditModal({ const currentMed = editingId ? meds.find((m) => m.id === editingId) : null; return ( -
-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + >
-

{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}

+

+ {(() => { + const editLabel = readOnlyMode ? t("form.viewEntry") : t("form.editEntry"); + return editingId ? editLabel : t("form.newEntry"); + })()} +

-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index c7ab3f8..71b240a 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -42,8 +42,18 @@ export function ShareDialog({ if (!show) return null; return ( -
-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > @@ -53,71 +63,79 @@ export function ShareDialog({

{t("share.description")}

- {sharePeople.length === 0 ? ( -
-

{t("share.noPeople")}

-
- ) : shareLink ? ( -
-

{t("share.linkGenerated")}

-
- (e.target as HTMLInputElement).select()} - /> - -
- {shareCopied && {t("share.copied")}} -
- - -
-
- ) : ( -
-
- - -
+ {(() => { + if (sharePeople.length === 0) { + return ( +
+

{t("share.noPeople")}

+
+ ); + } + if (shareLink) { + return ( +
+

{t("share.linkGenerated")}

+
+ (e.target as HTMLInputElement).select()} + /> + +
+ {shareCopied && {t("share.copied")}} +
+ + +
+
+ ); + } + return ( +
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
-
- )} + ); + })()}
); diff --git a/frontend/src/components/SharedSchedule.tsx b/frontend/src/components/SharedSchedule.tsx index 1f769e1..a5fcbf7 100644 --- a/frontend/src/components/SharedSchedule.tsx +++ b/frontend/src/components/SharedSchedule.tsx @@ -209,7 +209,7 @@ export function SharedSchedule() { // Get dose ID - for per-intake takenBy, the ID already has the person suffix // This helper is kept for compatibility but since dose.id already includes the suffix, it just returns the id - function getDoseId(doseId: string, _person: string | null): string { + function _getDoseId(doseId: string, _person: string | null): string { // The dose.id already includes the person suffix if there's a per-intake takenBy return doseId; } @@ -479,7 +479,8 @@ export function SharedSchedule() { const intake = intakes[blisterIdx]; const intakePerson = intake?.takenBy; - const peopleForThisIntake = intakePerson ? [intakePerson] : med.takenBy?.length > 0 ? med.takenBy : [null]; + const fallbackPeople = med.takenBy?.length > 0 ? med.takenBy : [null]; + const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople; let timeBasedConsumed = 0; let lastAutoConsumedDateMs = 0; @@ -579,7 +580,8 @@ export function SharedSchedule() { const status = getStockStatus(coverage.daysLeft, coverage.medsLeft, stockThresholds); return status.className; }); - return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; + const fallbackStatus = statuses.includes("warning") ? "warning" : "success"; + return statuses.includes("danger") ? "danger" : fallbackStatus; } // Whether to show stock status indicators on the shared schedule @@ -606,7 +608,7 @@ export function SharedSchedule() { const missedPastDoseIds = useMemo(() => { const allPastDoseIds = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id))); return allPastDoseIds.filter((id) => !isDoseIdDone(id)); - }, [pastDays, takenDoses, dismissedDoses, data]); + }, [pastDays, isDoseIdDone]); if (loading) { return ( @@ -714,14 +716,19 @@ export function SharedSchedule() {
-

- {t("share.period")}:{" "} - {data.scheduleDays === 30 - ? t("dashboard.schedules.1month") - : data.scheduleDays === 90 - ? t("dashboard.schedules.3months") - : t("dashboard.schedules.6months")} -

+ {(() => { + const periodLabel = + data.scheduleDays === 30 + ? t("dashboard.schedules.1month") + : data.scheduleDays === 90 + ? t("dashboard.schedules.3months") + : t("dashboard.schedules.6months"); + return ( +

+ {t("share.period")}: {periodLabel} +

+ ); + })()}
@@ -757,14 +764,18 @@ export function SharedSchedule() { const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; + const pastMissedClass = allDoseIds.length > 0 ? "past-missed" : ""; return (
0 ? "past-missed" : ""}`} + className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : pastMissedClass}`} >
toggleDayCollapse(day.dateStr, true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -817,6 +828,11 @@ export function SharedSchedule() {
med?.imageUrl && openLightbox(med.imageUrl, med.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openLightbox(med.imageUrl, med.name); + } + }} >
@@ -894,6 +910,9 @@ export function SharedSchedule() { }, 50); } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setShowPastDays(!showPastDays); + }} > {showPastDays ? "▼" : "▶"} @@ -941,6 +960,9 @@ export function SharedSchedule() {
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -982,6 +1004,11 @@ export function SharedSchedule() {
med?.imageUrl && openLightbox(med.imageUrl, med.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openLightbox(med.imageUrl, med.name); + } + }} >
@@ -1058,6 +1085,9 @@ export function SharedSchedule() {
setShowFutureDays(!showFutureDays)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays); + }} > {showFutureDays ? "▼" : "▶"} @@ -1099,6 +1129,9 @@ export function SharedSchedule() {
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -1139,6 +1172,11 @@ export function SharedSchedule() {
med?.imageUrl && openLightbox(med.imageUrl, med.name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openLightbox(med.imageUrl, med.name); + } + }} >
@@ -1215,7 +1253,13 @@ export function SharedSchedule() { {/* Image Lightbox */} {lightboxImage && ( -
+
{ + if (e.key === "Escape") closeLightbox(); + }} + > @@ -1224,6 +1268,7 @@ export function SharedSchedule() { alt={lightboxImage.name} className="lightbox-image" onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} />
)} diff --git a/frontend/src/components/UserFilterModal.tsx b/frontend/src/components/UserFilterModal.tsx index e82a9cc..8cb7efb 100644 --- a/frontend/src/components/UserFilterModal.tsx +++ b/frontend/src/components/UserFilterModal.tsx @@ -36,8 +36,18 @@ export function UserFilterModal({ const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser)); return ( -
-
e.stopPropagation()}> +
{ + if (e.key === "Escape") onClose(); + }} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > @@ -75,6 +85,12 @@ export function UserFilterModal({ onClearUser(); onOpenMedDetail(med); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + onClearUser(); + onOpenMedDetail(med); + } + }} >
diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index df02199..c17f006 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -6,7 +6,7 @@ import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, use import type { Coverage, Medication, ScheduleEvent, StockThresholds } from "../types"; import { getSystemLocale } from "../utils/formatters"; import { log } from "../utils/logger"; -import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, isDoseDismissed } from "../utils/schedule"; +import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds } from "../utils/schedule"; // ============================================================================= // Types @@ -366,7 +366,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) { // Normal/High stock return "success"; }); - return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; + const fallbackStatus = statuses.includes("warning") ? "warning" : "success"; + return statuses.includes("danger") ? "danger" : fallbackStatus; }, [coverageByMed, depletionByMed, settingsHook.settings.lowStockDays] ); @@ -536,7 +537,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { } setExporting(false); }, - [t] + [t, user?.username] ); // Handle file selection for import diff --git a/frontend/src/hooks/useMedicationForm.ts b/frontend/src/hooks/useMedicationForm.ts index 902a2a1..57302bb 100644 --- a/frontend/src/hooks/useMedicationForm.ts +++ b/frontend/src/hooks/useMedicationForm.ts @@ -215,6 +215,7 @@ export function useMedicationForm(): UseMedicationFormReturn { const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills); const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills); + const bottleTotalPills = med.packageType === "bottle" && med.looseTablets ? String(med.looseTablets) : ""; const editForm: FormState = { name: med.name, genericName: med.genericName ?? "", @@ -223,11 +224,7 @@ export function useMedicationForm(): UseMedicationFormReturn { packCount: String(med.packCount), blistersPerPack: String(med.blistersPerPack), pillsPerBlister: String(med.pillsPerBlister), - totalPills: med.totalPills - ? String(med.totalPills) - : med.packageType === "bottle" && med.looseTablets - ? String(med.looseTablets) - : "", + totalPills: med.totalPills ? String(med.totalPills) : bottleTotalPills, looseTablets: String(med.looseTablets), pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", doseUnit: med.doseUnit ?? "mg", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 762d180..970c560 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -82,7 +82,7 @@ export function getReminderStatusData( _allLowCoverage: Coverage[], allCoverage: Coverage[], lastAutoEmailSent: string | null, - lastNotificationType: string | null, + _lastNotificationType: string | null, _lastNotificationChannel: string | null, lastReminderMedName: string | null, lastReminderTakenBy: string | null, @@ -401,6 +401,11 @@ export function DashboardPage() { medication && openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (medication) openMedDetail(medication); + } + }} > {med.name} @@ -430,6 +435,11 @@ export function DashboardPage() { medication && openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (medication) openMedDetail(medication); + } + }} > {med.name} @@ -453,7 +463,13 @@ export function DashboardPage() { {idx > 0 && ", "} {medication ? ( - openMedDetail(medication)}> + openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openMedDetail(medication); + }} + > {name} ) : ( @@ -475,7 +491,13 @@ export function DashboardPage() { (() => { const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName); return medication ? ( - openMedDetail(medication)}> + openMedDetail(medication)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openMedDetail(medication); + }} + > {reminderData.lastIntakeSent!.medName} ) : ( @@ -553,7 +575,15 @@ export function DashboardPage() { return ( {idx > 0 && ", "} - med && openMedDetail(med)}> + med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > {c.name} @@ -603,7 +633,16 @@ export function DashboardPage() { med ? getMedTotal(med) : Math.round(row.medsLeft) ); return ( -
med && openMedDetail(med)}> +
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > @@ -629,6 +668,12 @@ export function DashboardPage() { e.stopPropagation(); openUserFilter(person); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + openUserFilter(person); + } + }} > {person} {med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"} @@ -740,7 +785,7 @@ export function DashboardPage() { const isAutoCollapsed = true; // Past days are always auto-collapsed const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; - const worstStatus = getDayStockStatus(day.meds); + const _worstStatus = getDayStockStatus(day.meds); return (
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -791,6 +839,11 @@ export function DashboardPage() {
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} >
@@ -833,6 +886,9 @@ export function DashboardPage() { openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} > {person} @@ -889,6 +945,19 @@ export function DashboardPage() { }, 50); } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + const wasCollapsed = !showPastDays; + setShowPastDays(!showPastDays); + if (wasCollapsed) { + setTimeout(() => { + document + .querySelector(".day-block.today") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 50); + } + } + }} > {showPastDays ? "▼" : "▶"} @@ -963,6 +1032,9 @@ export function DashboardPage() {
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -998,6 +1070,11 @@ export function DashboardPage() {
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} >
@@ -1044,6 +1121,9 @@ export function DashboardPage() { openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} > {person} @@ -1096,6 +1176,9 @@ export function DashboardPage() {
setShowFutureDays(!showFutureDays)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setShowFutureDays(!showFutureDays); + }} > {showFutureDays ? "▼" : "▶"} @@ -1150,6 +1233,9 @@ export function DashboardPage() {
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, isAutoCollapsed); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -1185,6 +1271,11 @@ export function DashboardPage() {
med?.imageUrl && openScheduleLightbox(`/api/images/${med.imageUrl}`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med?.imageUrl) openScheduleLightbox(`/api/images/${med.imageUrl}`); + } + }} >
@@ -1227,6 +1318,9 @@ export function DashboardPage() { openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} > {person} diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 6273994..a9412de 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -621,6 +621,11 @@ export function MedicationsPage() { onClick={() => med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }); + } + }} > @@ -738,6 +743,12 @@ export function MedicationsPage() { onClick={() => med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }) } + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med.imageUrl) + setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name }); + } + }} > diff --git a/frontend/src/pages/PlannerPage.tsx b/frontend/src/pages/PlannerPage.tsx index ddaac3b..78e3d14 100644 --- a/frontend/src/pages/PlannerPage.tsx +++ b/frontend/src/pages/PlannerPage.tsx @@ -206,7 +206,16 @@ export function PlannerPage() { meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName); const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null; return ( -
med && openMedDetail(med)}> +
med && openMedDetail(med)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (med) openMedDetail(med); + } + }} + > {row.medicationName} diff --git a/frontend/src/pages/SchedulePage.tsx b/frontend/src/pages/SchedulePage.tsx index 113c0e5..5515d10 100644 --- a/frontend/src/pages/SchedulePage.tsx +++ b/frontend/src/pages/SchedulePage.tsx @@ -129,7 +129,7 @@ export function SchedulePage() { const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; - const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings); + const _worstStatus = getDayStockStatus(day.meds, coverageByMed, settings); return (
toggleDayCollapse(day.dateStr, true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") toggleDayCollapse(day.dateStr, true); + }} title={isCollapsed ? t("common.expand") : t("common.collapse")} > {isCollapsed ? "▶" : "▼"} @@ -210,6 +213,9 @@ export function SchedulePage() { openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} > {person} @@ -264,6 +270,19 @@ export function SchedulePage() { }, 50); } }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + const wasCollapsed = !showPastDays; + setShowPastDays(!showPastDays); + if (wasCollapsed) { + setTimeout(() => { + document + .querySelector(".day-block.today") + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 50); + } + } + }} > {showPastDays ? "▼" : "▶"} @@ -351,7 +370,13 @@ export function SchedulePage() { className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`} > {person && ( - openUserFilter(person)}> + openUserFilter(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") openUserFilter(person); + }} + > {person} )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0c67076..805fa59 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -664,6 +664,13 @@ body.modal-open { border-radius: 8px; padding: 0.1rem 0.25rem; margin: -0.1rem -0.25rem 0.8rem; + background: none; + box-shadow: none; + color: inherit; +} + +.med-group-head-toggle:hover { + background: var(--bg-tertiary); } .med-group-head-toggle:hover .med-group-title { diff --git a/frontend/src/test/components/MedDetailModal.test.tsx b/frontend/src/test/components/MedDetailModal.test.tsx index 1218c65..5367b7f 100644 --- a/frontend/src/test/components/MedDetailModal.test.tsx +++ b/frontend/src/test/components/MedDetailModal.test.tsx @@ -706,7 +706,7 @@ describe("MedDetailModal bottle package type", () => { expect(screen.getByText(/refill\.pillsToAdd/i)).toBeInTheDocument(); // Should NOT show packs label in refill - const refillModal = document.querySelector(".refill-modal"); + const _refillModal = document.querySelector(".refill-modal"); // Packs label should not be present for bottle type expect(screen.queryByText("refill.packs")).not.toBeInTheDocument(); }); diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index 15ca721..dfcc763 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -1892,11 +1892,8 @@ function groupEventsIntoPastDays( const medMap = dayMap.get(dateKey)!; if (!medMap.has(event.medName)) medMap.set(event.medName, []); // Mirror AppContext normalization: string|null → string[] - const takenBy = Array.isArray(event.takenBy) - ? event.takenBy - : typeof event.takenBy === "string" - ? [event.takenBy] - : []; + const singleOrEmpty = typeof event.takenBy === "string" ? [event.takenBy] : []; + const takenBy = Array.isArray(event.takenBy) ? event.takenBy : singleOrEmpty; medMap.get(event.medName)!.push({ id: event.id, takenBy }); } diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index 716c2d9..f3ab444 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -171,7 +171,8 @@ export function calculateCoverage( // For per-intake takenBy, only count for that person // For legacy (no takenBy), count for all people in medication takenBy - const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null]; + const fallbackPeople = m.takenBy?.length > 0 ? m.takenBy : [null]; + const peopleForThisIntake = intakePerson ? [intakePerson] : fallbackPeople; // Time-based: count doses where the scheduled time has already passed let timeBasedConsumed = 0;