chore: fix all Biome lint warnings and MedDetail intake bell icons (#265)

- Backend: refactor nested ternaries, remove unused imports/any types
- Frontend: fix exhaustive deps, a11y label associations, array index keys,
  empty CSS blocks, unused vars, type annotations
- MedDetail modal: fix intake schedule bell icons not rendering (use unified
  intake source with fallback), place bell inline after person name
- MedDetail modal: revert schedule rows from grid to flexbox layout

Closes #264
This commit is contained in:
Daniel Volz
2026-02-22 08:52:03 +01:00
committed by GitHub
parent 228fd4cd7e
commit 088a6c1a05
24 changed files with 159 additions and 78 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
* auth.spec.ts should keep importing from `@playwright/test` directly
* since it tests the unauthenticated flow.
*/
export const test = base.extend<{}>({
export const test = base.extend<object>({
page: async ({ page }, use) => {
await setupAuthMeMock(page);
await use(page);
+20 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
"version": "1.12.0",
"version": "1.14.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.12.0",
"version": "1.14.2",
"dependencies": {
"i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1",
@@ -23,6 +23,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
@@ -1735,6 +1736,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3302,6 +3313,13 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+1
View File
@@ -40,6 +40,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.0",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
+16 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import {
AboutModal,
@@ -119,8 +119,6 @@ function AppContent() {
// Medications
meds,
loadMeds,
// Settings
settings,
// Refill
showRefillModal,
setShowRefillModal,
@@ -190,6 +188,17 @@ function AppContent() {
// Local-only state (not shared across components)
const [showProfile, setShowProfile] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const closeProfile = useCallback(() => {
if (showProfile) {
window.history.back();
}
}, [showProfile]);
const closeAbout = useCallback(() => {
if (showAbout) {
window.history.back();
}
}, [showAbout]);
// Get centralized stockThresholds from context
const { stockThresholds } = ctx;
@@ -389,25 +398,15 @@ function AppContent() {
openEditStockModal(selectedMed, coverage);
};
function openProfile() {
const openProfile = useCallback(() => {
setShowProfile(true);
window.history.pushState({ modal: "profile" }, "");
}
function closeProfile() {
if (showProfile) {
window.history.back();
}
}
}, []);
function openAbout() {
const openAbout = useCallback(() => {
setShowAbout(true);
window.history.pushState({ modal: "about" }, "");
}
function closeAbout() {
if (showAbout) {
window.history.back();
}
}
}, []);
return (
<main className="page">
+3 -2
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: auth refresh callbacks intentionally coordinate via refs/guards */
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { log } from "../utils/logger";
@@ -70,7 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
initialFetchDone.current = true;
fetchAuthState();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [fetchAuthState]);
// Proactively refresh token every 10 minutes to prevent expiration
useEffect(() => {
@@ -89,7 +90,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => clearInterval(refreshInterval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, authState?.authEnabled]);
}, [user, authState?.authEnabled, refreshUser, tryRefreshToken]);
async function fetchAuthState(retryCount = 0) {
const maxRetries = 3;
+9 -1
View File
@@ -31,7 +31,15 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
}
return (
<div className="lightbox-overlay" onClick={handleOverlayClick}>
<div
className="lightbox-overlay"
onClick={handleOverlayClick}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClose();
}
}}
>
<div className="lightbox-container">
<button className="lightbox-close" onClick={onClose}>
×
+33 -17
View File
@@ -6,6 +6,8 @@
* 1. Context mode: Uses useAppContext() for all state (when no props provided)
* 2. Props mode: Accepts all required data as props (for gradual adoption)
*/
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses label-styled wrappers with custom interactive rows */
/* biome-ignore-all lint/style/noNestedTernary: stock/preview rendering keeps explicit branch mapping */
import { Bell, Calendar, ClipboardList, FilePenLine, Minus, NotebookPen, Pencil, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
@@ -474,7 +476,7 @@ export function MedDetailModal({
const rawFull = raw === "" ? 0 : Math.max(0, parseStockInput(raw));
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
setEditStockFullInput(raw);
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
onEditStockFullBlistersChange(normalized.full);
@@ -503,7 +505,7 @@ export function MedDetailModal({
const rawFull = Math.max(0, parseStockInput(editStockFullInput) + delta);
const rawPartial = Math.max(0, parseStockInput(editStockPartialInput));
const rawLoose = Math.max(0, parseStockInput(editStockLooseInput));
const rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
const _rawTotal = rawFull * selectedMed.pillsPerBlister + rawPartial + rawLoose;
const normalized = normalizeBlisterStock(rawFull, rawPartial, rawLoose);
onEditStockFullBlistersChange(normalized.full);
onEditStockPartialBlisterPillsChange(normalized.partial);
@@ -560,7 +562,7 @@ export function MedDetailModal({
const nextPartial = Math.max(0, parseStockInput(editStockPartialInput) + delta);
const nextFull = Math.max(0, parseStockInput(editStockFullInput));
const nextLoose = Math.max(0, parseStockInput(editStockLooseInput));
const rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
const _rawTotal = nextFull * selectedMed.pillsPerBlister + nextPartial + nextLoose;
const normalized = normalizeBlisterStock(nextFull, nextPartial, nextLoose);
onEditStockFullBlistersChange(normalized.full);
onEditStockPartialBlisterPillsChange(normalized.partial);
@@ -815,35 +817,49 @@ export function MedDetailModal({
)}
</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;
{(selectedMed.intakes && selectedMed.intakes.length > 0
? selectedMed.intakes
: selectedMed.blisters.map((blister) => ({
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy: null,
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false,
}))
).map((intake, idx) => {
const hasPerIntakeTakenBy = !!intake.takenBy;
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false;
return (
<div key={idx} className="med-schedule-item">
<div key={`${intake.start}-${intake.usage}-${intake.every}-${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 })}
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
</span>
{hasPerIntakeTakenBy && intake.takenBy && (
<span className="med-schedule-person">{intake.takenBy}</span>
{hasPerIntakeTakenBy && (
<span className="med-schedule-person">
{intake.takenBy}
{showIntakeBell && (
<span className="med-schedule-bell" role="img" aria-label={t("tooltips.intakeReminders")}>
<Bell size={13} aria-hidden="true" />
</span>
)}
</span>
)}
{intake?.intakeRemindersEnabled && (
{!hasPerIntakeTakenBy && showIntakeBell && (
<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), {
{new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit",
minute: "2-digit",
})}
+8 -4
View File
@@ -3,6 +3,7 @@
* Handles new medication creation and editing existing medications
*/
/* biome-ignore-all lint/a11y/noLabelWithoutControl: modal uses custom DateInput and static value fields */
import { Bell, Minus, Plus, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -91,9 +92,9 @@ export function MobileEditModal({
onAddTakenByPerson,
onRemoveTakenByPerson,
onTakenByKeyDown,
onSetBlisterValue,
onAddBlister,
onRemoveBlister,
_onSetBlisterValue,
_onAddBlister,
_onRemoveBlister,
onSetIntakeValue,
onAddIntake,
onRemoveIntake,
@@ -639,7 +640,10 @@ export function MobileEditModal({
)}
</div>
{form.intakes.map((intake, idx) => (
<div key={idx} className="blister-row">
<div
key={`${intake.startDate}-${intake.startTime}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`}
className="blister-row"
>
<label className="compact">
<span>{t("form.blisters.usage")}</span>
<input
+12 -4
View File
@@ -124,8 +124,12 @@ export function ShareDialog({
return (
<div className="share-dialog-form">
<div className="form-group">
<label>{t("share.selectPerson")}</label>
<select value={shareSelectedPerson} onChange={(e) => onShareSelectedPersonChange(e.target.value)}>
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
<select
id="share-person-select"
value={shareSelectedPerson}
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
>
{sharePeople.map((person) => (
<option key={person} value={person}>
{person}
@@ -135,8 +139,12 @@ export function ShareDialog({
</div>
<div className="form-group">
<label>{t("share.selectPeriod")}</label>
<select value={shareSelectedDays} onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}>
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
<select
id="share-period-select"
value={shareSelectedDays}
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
>
<option value={30}>{t("dashboard.schedules.1month")}</option>
<option value={90}>{t("dashboard.schedules.3months")}</option>
<option value={180}>{t("dashboard.schedules.6months")}</option>
@@ -1,6 +1,8 @@
// =============================================================================
// SharedSchedule Component - Public view for shared schedules
// =============================================================================
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+3 -2
View File
@@ -98,13 +98,14 @@ export function UserFilterModal({
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
{personIntakes.length > 0 && (
<div className="user-med-intakes">
{personIntakes.map((intake, idx) => {
{personIntakes.map((intake) => {
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
hour: "2-digit",
minute: "2-digit",
});
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
return (
<span key={idx} className="user-med-intake-item">
<span key={intakeKey} className="user-med-intake-item">
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
{med.pillWeightMg != null &&
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
+1 -1
View File
@@ -151,7 +151,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
if (error) errors[f] = error;
});
setFieldErrors(errors);
}, [form.name, form.genericName, form.notes, validateField]);
}, [form.name, form.genericName, form.notes, validateField, form]);
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
setForm((prev) => {
+1
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/style/noNestedTernary: timeline rendering uses explicit UI-state branching */
import { Bell, NotebookPen, Share2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
+4 -2
View File
@@ -1,3 +1,6 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: form uses custom inputs and display fields wrapped in label-like layout */
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal-history callbacks are intentionally managed outside hook deps */
/* biome-ignore-all lint/suspicious/noArrayIndexKey: local draft intake rows do not have stable ids before persistence */
import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -7,7 +10,7 @@ import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, Medication } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
import { log } from "../utils/logger";
@@ -31,7 +34,6 @@ export function MedicationsPage() {
deleteMedImage,
uploadingImage,
existingPeople,
coverage,
coverageByMed,
} = useAppContext();
+1
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DateTimeInput, MedicationAvatar } from "../components";
+1
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
+1
View File
@@ -1,3 +1,4 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context";
+6 -5
View File
@@ -4605,9 +4605,6 @@ button.has-validation-error {
align-items: start;
}
.prescription-detail-grid .med-detail-value {
}
.med-detail-item {
background: var(--bg-secondary);
padding: 0.75rem;
@@ -4650,8 +4647,8 @@ button.has-validation-error {
.med-schedule-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1rem;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
background: var(--bg-secondary);
padding: 0.75rem 1rem;
border-radius: 8px;
@@ -4665,22 +4662,26 @@ button.has-validation-error {
.med-schedule-freq {
color: var(--text-secondary);
white-space: nowrap;
}
.med-schedule-time {
font-weight: 500;
margin-left: auto;
white-space: nowrap;
}
.med-schedule-person {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
}
.med-schedule-bell {
color: var(--warning);
display: inline-flex;
align-items: center;
margin-left: 0.35rem;
}
[data-theme="light"] .med-schedule-bell {
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useMedications } from "../../hooks/useMedications";
import type { Medication } from "../../types";
describe("useMedications", () => {
beforeEach(() => {
@@ -193,7 +194,7 @@ describe("useMedications", () => {
it("allows setting meds directly", () => {
const { result } = renderHook(() => useMedications());
const newMeds = [{ id: 1, name: "NewMed" }] as any;
const newMeds: Array<Pick<Medication, "id" | "name">> = [{ id: 1, name: "NewMed" }];
act(() => {
result.current.setMeds(newMeds);