frontend: clear the 99 lint errors

`npm run lint` reported 99 errors and 26 warnings across 65 files and
had presumably been failing for a while — next.config.ts sets
eslint.ignoreDuringBuilds, so the build never surfaced it.

68 were in vendored code: components/charts and components/ai-elements,
pulled from upstream registries. Re-linting those reports upstream's
style back at us, and "fixing" them means diverging and eating conflicts
on every update — ai-elements already has exactly this carve-out on the
TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry
files in components/ui (carousel publishes its api from an effect; the
sidebar skeleton picks a random width).

The other 31 were ours, nearly all react-hooks/set-state-in-effect on
the same shape: an effect that re-seeds form state when a dialog opens or
a selection changes. Moved to render-phase adjustment, which is both what
React recommends and a real fix — the effect version paints one frame of
the *previous* record's values before correcting itself. Two carried
sharper bugs: the employee dialog could keep a typed password across a
switch to another member, and use-wallet-sync could carry `linked` over
to a newly-selected patient, briefly offering to push a record to
someone else's wallet.

The rest: useIsMobile and speech-support detection become
useSyncExternalStore (correct on first paint, no mount flash); refs
mirroring state are written in effects rather than during render; the
care-team fetch moves into its effect behind a reload key, dropping an
exhaustive-deps suppression.

Two effects in chat-panel keep the rule disabled with a reason. Both are
what effects are for: draining the queued-message buffer when the
transport goes idle, and resolving ?thread from the URL — the latter
mints an id with nanoid(), so moving it into render would just trade this
error for a purity one.

Also removes a dead /explore-era import and unused directives found on
the way. lint now exits 0, with the ai-elements tsc carve-out unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-15 20:54:37 +03:00
parent 7c92c03eed
commit e8f3ed9ffe
28 changed files with 325 additions and 136 deletions
@@ -87,15 +87,18 @@ export function AppointmentDetailSheet({
const [status, setStatus] = useState<AppointmentStatus>("confirmed");
const [busy, setBusy] = useState(false);
// Seed the form from the selected appointment whenever it changes.
useEffect(() => {
if (!appt) return;
// Seed the form from the selected appointment whenever it changes. Adjusted
// during render rather than in an effect, so the sheet never paints one frame
// of the previous appointment's values.
const [prevAppt, setPrevAppt] = useState(appt);
if (appt && prevAppt !== appt) {
setPrevAppt(appt);
setDate(new Date(`${appt.date}T00:00:00`));
setTime(appt.time);
setType(appt.type);
setProvider(appt.provider);
setStatus(appt.status);
}, [appt]);
}
useEffect(() => {
if (!open) return;
@@ -1,7 +1,7 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -72,13 +72,22 @@ export function CalendarDialog({
});
const [selectedKey, setSelectedKey] = useState<string>(startKey);
// When opened via a deep-link date, jump the view to that month/day.
useEffect(() => {
if (!open || !initialDate || !/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) return;
const d = parseKey(initialDate);
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setSelectedKey(initialDate);
}, [open, initialDate]);
// When opened via a deep-link date, jump the view to that month/day. Adjusted
// during render rather than in an effect, so the calendar never paints the
// current month before jumping to the linked one.
const jumpTo =
open && initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate)
? initialDate
: null;
const [prevJumpTo, setPrevJumpTo] = useState<string | null>(null);
if (prevJumpTo !== jumpTo) {
setPrevJumpTo(jumpTo);
if (jumpTo) {
const d = parseKey(jumpTo);
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setSelectedKey(jumpTo);
}
}
const byDate = useMemo(() => {
const map = new Map<string, Appointment[]>();
+12 -5
View File
@@ -9,6 +9,7 @@ import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
@@ -78,17 +79,23 @@ export function ChatInput({
const [addKey, setAddKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
// first client render agree (button starts disabled, enabled by the effect).
const [speechSupported, setSpeechSupported] = useState(false);
// Voice dictation (Web Speech API). Read through useSyncExternalStore so SSR
// markup and the first client render agree: the server snapshot is `false`
// (no Speech API to detect), and the client re-reads on hydration. Support
// never changes for the life of the page, so the subscription is a no-op.
const speechSupported = useSyncExternalStore(
() => () => {},
() => getSpeechRecognition() !== null,
() => false,
);
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
// The textarea contents when dictation started; transcript is appended to it.
const dictationBaseRef = useRef("");
useEffect(() => {
setSpeechSupported(getSpeechRecognition() !== null);
return () => recognitionRef.current?.stop();
const recognition = recognitionRef;
return () => recognition.current?.stop();
}, []);
const toggleDictation = useCallback(() => {
+23 -3
View File
@@ -139,8 +139,14 @@ export function ChatPanel() {
// Persisted conversation: a client-owned thread id (a fresh one per new chat),
// saved to the server after each exchange so history survives reloads.
const [threadId, setThreadId] = useState<string>(() => nanoid());
// Mirrored into a ref for the transport's fetch closure, which is memoized and
// must not be rebuilt per thread. Written in an effect rather than during
// render: a render can be thrown away or replayed, and mutating a ref there
// makes it observable — the write has to happen once the render is committed.
const threadIdRef = useRef(threadId);
threadIdRef.current = threadId;
useEffect(() => {
threadIdRef.current = threadId;
}, [threadId]);
// Skip the auto-save that would otherwise fire right after loading a thread
// (which would needlessly bump it to the top of the history).
const justLoadedRef = useRef(false);
@@ -214,9 +220,11 @@ export function ChatPanel() {
// stays visible until acknowledged and isn't duplicated. Reset the dismissed
// flag whenever a fresh error arrives.
const [errorDismissed, setErrorDismissed] = useState(false);
useEffect(() => {
const [prevError, setPrevError] = useState(error);
if (prevError !== error) {
setPrevError(error);
if (error) setErrorDismissed(false);
}, [error]);
}
const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama";
@@ -301,9 +309,16 @@ export function ChatPanel() {
);
// Drain the queue one message at a time whenever the chat returns to idle.
//
// This is the shape the rule exists to catch, but it's the shape we want: the
// trigger is the transport going idle — an external async system — and the
// dequeue has to happen in the same tick we hand the message to `send`, or a
// re-render could drain it twice. There's no render-phase equivalent, since
// `send` is a side effect.
useEffect(() => {
if (status !== "ready" || pendingConsent || queued.length === 0) return;
const [next, ...rest] = queued;
// eslint-disable-next-line react-hooks/set-state-in-effect
setQueued(rest);
if (next) void send(next.text, next.files);
}, [status, pendingConsent, queued, send]);
@@ -338,6 +353,10 @@ export function ChatPanel() {
// Open a saved thread from `/?thread=<id>` (sidebar history); a bare `/` starts
// a fresh chat. Driven by the URL so the sidebar links and "New chat" work.
//
// Stays an effect: the thread branch is an async fetch, and the fresh-chat
// branch mints an id with nanoid(), which is impure and so can't be adjusted
// during render — moving it there would just trade this for a purity error.
const requestedThread = searchParams.get("thread");
useEffect(() => {
if (requestedThread) {
@@ -367,6 +386,7 @@ export function ChatPanel() {
};
}
// No ?thread → fresh chat (e.g. after "New chat").
// eslint-disable-next-line react-hooks/set-state-in-effect
setThreadId(nanoid());
setMessages([]);
}, [requestedThread, setMessages]);
@@ -6,7 +6,7 @@
// array fields (invoice line items, inventory items) edited as add/remove rows.
import { Plus, X } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -196,10 +196,14 @@ export function RecordEditDialog({
const schema = EDIT_SCHEMAS[kind];
const [draft, setDraft] = useState<Rec>(record);
// Re-seed the draft whenever a fresh record is opened for editing.
useEffect(() => {
if (open) setDraft(record);
}, [open, record]);
// Re-seed the draft whenever a fresh record is opened for editing. Adjusted
// during render so the form never paints the previous record's values.
const [prevSeed, setPrevSeed] = useState<Rec | null>(null);
const seed = open ? record : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setDraft(seed);
}
const setField = (key: string, value: unknown) =>
setDraft((d) => ({ ...d, [key]: value }));
+1 -1
View File
@@ -75,7 +75,7 @@ const dotClass: Record<Kind, string> = {
// A round node with the label below it and hidden connection handles so edges
// meet the dot's centre cleanly. Memoized; it re-renders only when the hovered
// id changes (via context), never via React Flow re-measuring.
const RecordNode = memo(function RecordNode({ id, data }: NodeProps) {
const RecordNode = memo(function RecordNode({ data }: NodeProps) {
const { label, sub, kind, family } = data as RecordNodeData;
const hovered = useContext(HoverContext);
const focus: "active" | "dim" | null =
@@ -7,7 +7,7 @@ import {
Split,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
@@ -67,9 +67,14 @@ export function InvoiceDetailSheet({
const [count, setCount] = useState(3);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Collapse the "show more" list back when a different invoice is shown.
// Adjusted during render so the sheet never paints the previous invoice's
// expanded state.
const [prevInvoiceId, setPrevInvoiceId] = useState(invoice?.id);
if (prevInvoiceId !== invoice?.id) {
setPrevInvoiceId(invoice?.id);
setCount(3);
}, [invoice?.id]);
}
if (!invoice) {
return (
@@ -172,10 +172,14 @@ export function InvoiceFormDialog({
onOpenChange(next);
};
// Seed the form when opening.
useEffect(() => {
if (!open) return;
if (mode === "edit" && invoice) {
// Seed the form when opening. Adjusted during render rather than in an
// effect, so the dialog never paints one frame of the previous invoice.
// `false` means closed, so reopening on the same invoice re-seeds.
const [prevSeed, setPrevSeed] = useState<Invoice | null | false>(false);
const seed = open ? (invoice ?? null) : false;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (open && mode === "edit" && invoice) {
setIssuedAt(new Date(`${invoice.issuedAt}T00:00:00`));
// Existing invoices legitimately carry past issue dates.
setAllowBackdate(true);
@@ -186,7 +190,7 @@ export function InvoiceFormDialog({
setLineItems(
invoice.lineItems.length ? invoice.lineItems : [emptyLine()],
);
} else {
} else if (open) {
setSelected(null);
setIssuedAt(new Date());
setAllowBackdate(false);
@@ -196,7 +200,7 @@ export function InvoiceFormDialog({
setNotes("");
setLineItems([emptyLine()]);
}
}, [open, mode, invoice]);
}
// Load patients lazily for the create combobox.
useEffect(() => {
@@ -76,7 +76,6 @@ function VideoTile({
speaking ? "border-success" : "border-transparent",
)}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
autoPlay
className={cn("size-full object-cover", !showVideo && "invisible")}
+11 -10
View File
@@ -59,7 +59,6 @@ export function MeetingsView() {
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
// pre-targeted at that person so the user can connect with them.
const deepLinkWith = searchParams.get("with");
const openedDeepLink = useRef<string | null>(null);
const openedWith = useRef<string | null>(null);
const [tab, setTab] = useState<Tab>("rooms");
@@ -104,16 +103,18 @@ export function MeetingsView() {
};
}, []);
// Auto-join a room deep-linked from an invite (?room=).
useEffect(() => {
if (!deepLinkRoom || rooms.length === 0) return;
if (openedDeepLink.current === deepLinkRoom) return;
// Auto-join a room deep-linked from an invite (?room=). Resolved during
// render once the room list arrives, so the room opens on the same paint the
// list does instead of flashing the default tab first.
const [openedDeepLink, setOpenedDeepLink] = useState<string | null>(null);
if (deepLinkRoom && rooms.length > 0 && openedDeepLink !== deepLinkRoom) {
const room = rooms.find((r) => r.id === deepLinkRoom);
if (!room) return;
openedDeepLink.current = deepLinkRoom;
setTab("rooms");
setActiveRoom(room);
}, [deepLinkRoom, rooms]);
if (room) {
setOpenedDeepLink(deepLinkRoom);
setTab("rooms");
setActiveRoom(room);
}
}
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
useEffect(() => {
@@ -54,12 +54,21 @@ export function ScheduleMeetingDialog({
const [picked, setPicked] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous meeting's title or participants.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
}
}
useEffect(() => {
if (!open) return;
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set(defaultParticipants ?? []));
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
@@ -8,11 +8,17 @@ import { useEffect, useState } from "react";
export function useSpeaking(stream: MediaStream | null): boolean {
const [speaking, setSpeaking] = useState(false);
// Stop reporting "speaking" the moment the stream goes away or changes, so a
// muted tile can't keep a stale ring. Adjusted during render rather than in
// the effect below, which is only for driving the Web Audio graph.
const [prevStream, setPrevStream] = useState(stream);
if (prevStream !== stream) {
setPrevStream(stream);
setSpeaking(false);
}
useEffect(() => {
if (!stream || stream.getAudioTracks().length === 0) {
setSpeaking(false);
return;
}
if (!stream || stream.getAudioTracks().length === 0) return;
let ctx: AudioContext | null = null;
let raf = 0;
try {
@@ -245,10 +245,15 @@ export function MessagesView() {
const [appts, setAppts] = useState<Appointment[]>([]);
const [apptQuery, setApptQuery] = useState("");
// Refs so the socket handler (registered once) reads current values.
// Refs so the socket handler (registered once) reads current values. Written
// in an effect rather than during render: a render can be thrown away or
// replayed, and mutating a ref there makes it observable — the write has to
// happen once the render is committed.
const selectedIdRef = useRef<string | null>(null);
const myIdRef = useRef<string>("");
myIdRef.current = myId;
useEffect(() => {
myIdRef.current = myId;
}, [myId]);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -354,7 +359,6 @@ export function MessagesView() {
openedDeepLink.current = deepLinkConversation;
open(deepLinkConversation);
// `open` is stable enough for this one-shot; deps intentionally minimal.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkConversation, conversations]);
const send = (event: FormEvent) => {
+3
View File
@@ -61,6 +61,9 @@ export function NotesView() {
return () => {
active = false;
};
// `t` intentionally omitted: it is only read to build a failure message,
// and re-fetching on a language change would be pointless.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const startNew = () => {
@@ -79,8 +79,12 @@ export function ImportFromWalletDialog({
}
};
// Reset everything whenever the dialog is (re)opened.
useEffect(() => {
// Reset everything whenever the dialog is (re)opened. Adjusted during render
// so a reopened dialog never flashes the previous import's wallet number or
// state before clearing.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setMode("number");
setWalletNumber("");
@@ -92,8 +96,10 @@ export function ImportFromWalletDialog({
setPairUri(null);
setReviewOpen(false);
}
return stopPolling;
}, [open]);
}
// Stop polling when the dialog closes or unmounts.
useEffect(() => stopPolling, [open]);
// Poll the request until the patient approves/denies on their device.
useEffect(() => {
@@ -102,14 +102,29 @@ export function PatientDetailSheet({
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
// Clear the previous chart the moment a different one is opened, so the sheet
// can't show one patient's data under another's name while the fetch is in
// flight. Adjusted during render rather than in the effects below, which are
// only for the fetching itself. Keyed on the role too, so losing clinical
// access drops the wallet state instead of leaving the "Push update" button
// lit from the previous render.
const chart = open ? `${fileNumber ?? ""}|${role ?? ""}` : null;
const [prevChart, setPrevChart] = useState<string | null>(null);
if (prevChart !== chart) {
setPrevChart(chart);
setWalletLinked(false);
if (chart) {
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
}
}
useEffect(() => {
if (!open || !fileNumber) return;
let active = true;
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
getPatient(fileNumber)
.then((data) => {
if (!active) return;
@@ -138,9 +153,10 @@ export function PatientDetailSheet({
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
// refetching the record. Only clinicians can push. The reset rides on the
// same render-phase clear as the chart above, so the button can't stay lit
// from the previous patient.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
@@ -76,9 +76,10 @@ export function PatientsView() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Runs once on mount, and `loading` already starts true, so there's nothing
// to flip synchronously here.
useEffect(() => {
let active = true;
setLoading(true);
listPatients()
.then((data) => {
if (!active) return;
@@ -97,6 +98,9 @@ export function PatientsView() {
return () => {
active = false;
};
// `t` intentionally omitted: it is only read to build a failure message,
// and re-fetching on a language change would be pointless.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const q = query.trim().toLowerCase();
@@ -44,10 +44,19 @@ export function TransferPatientDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-seed the form each time the dialog opens. Adjusted during render so it
// never paints the previous patient's provider.
const [prevOpen, setPrevOpen] = useState(false);
if (prevOpen !== open) {
setPrevOpen(open);
if (open) {
setProviderId(patient.primaryProviderId ?? "");
setError(null);
}
}
useEffect(() => {
if (!open) return;
setProviderId(patient.primaryProviderId ?? "");
setError(null);
let active = true;
listProviders()
.then((list) => active && setProviders(list))
@@ -221,9 +221,19 @@ export function AddPrescriptionDialog({
.slice(0, 6);
}, [inventory, medication]);
// Keep the highlighted option in range as the result lists change.
useEffect(() => setActiveIndex(0), [query]);
useEffect(() => setMedIndex(0), [medication]);
// Keep the highlighted option in range as the result lists change. Adjusted
// during render rather than in an effect: React re-runs this render before
// committing, so the list never paints with a stale highlight.
const [prevQuery, setPrevQuery] = useState(query);
if (prevQuery !== query) {
setPrevQuery(query);
setActiveIndex(0);
}
const [prevMedication, setPrevMedication] = useState(medication);
if (prevMedication !== medication) {
setPrevMedication(medication);
setMedIndex(0);
}
const pickPatient = (p: Patient) => {
setSelected(p);
@@ -8,7 +8,7 @@ import {
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -115,12 +115,19 @@ export function EmployeeDetailDialog({
const [confirmPw, setConfirmPw] = useState("");
const [savingPw, setSavingPw] = useState(false);
useEffect(() => {
// Re-seed when a different member (or their role/specialty) is shown, and
// always clear the password fields so they can't carry across. Adjusted
// during render rather than in an effect, so the dialog never paints another
// member's values — and a typed password never survives a switch.
const seed = `${member?.id ?? ""}|${member?.role ?? ""}|${member?.specialty ?? ""}`;
const [prevSeed, setPrevSeed] = useState(seed);
if (prevSeed !== seed) {
setPrevSeed(seed);
setRole(member?.role ?? "");
setSpecialty(member?.specialty ?? "");
setNewPw("");
setConfirmPw("");
}, [member?.id, member?.role, member?.specialty]);
}
const summary = rolePermissionSummary(member?.role);
const secondary = member?.username ? `@${member.username}` : member?.email;
@@ -1,7 +1,7 @@
"use client";
import { Info, UserPlus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AddStaffDialog } from "@/components/settings/add-staff-dialog";
@@ -64,36 +64,47 @@ export function CareTeamPanel({
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null);
const [removing, setRemoving] = useState(false);
const load = useCallback(async () => {
try {
const data = await apiFetch<StaffMember[]>("/api/staff");
setMembers(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// The effect owns the fetch; `reload()` just bumps a key to re-run it. That
// keeps every setState inside a promise callback rather than running
// synchronously when the effect fires, and it drops the exhaustive-deps
// suppression the old useCallback needed.
const [reloadKey, setReloadKey] = useState(0);
const reload = () => setReloadKey((k) => k + 1);
useEffect(() => {
void load();
}, [load]);
let active = true;
apiFetch<StaffMember[]>("/api/staff")
.then((data) => {
if (!active) return;
setMembers(data);
setError(null);
})
.catch((err: unknown) => {
if (!active) return;
setError(
err instanceof Error ? err.message : t("settings.careTeam.loadError"),
);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
// `t` is intentionally omitted: re-fetching the staff list on a language
// change would be pointless, and the message is only read on failure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reloadKey]);
// Deep-link: open a specific member (e.g. from a password-reset system card).
const appliedDeepLink = useRef(false);
useEffect(() => {
if (appliedDeepLink.current || !initialMemberId || members.length === 0)
return;
// Resolved during render once the list arrives, so the member's dialog opens
// on the same paint the list does.
const [appliedDeepLink, setAppliedDeepLink] = useState(false);
if (!appliedDeepLink && initialMemberId && members.length > 0) {
setAppliedDeepLink(true);
const target = members.find((m) => m.userId === initialMemberId);
if (target) {
setSelected(target);
appliedDeepLink.current = true;
}
}, [initialMemberId, members]);
if (target) setSelected(target);
}
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
const canManage = myRole === "owner" || myRole === "admin";
@@ -119,7 +130,7 @@ export function CareTeamPanel({
}),
);
setPendingRemove(null);
void load();
reload();
};
return (
@@ -211,7 +222,7 @@ export function CareTeamPanel({
{canManage && (
<AddStaffDialog
onCreated={() => void load()}
onCreated={reload}
onOpenChange={setAdding}
open={adding}
/>
@@ -225,7 +236,7 @@ export function CareTeamPanel({
selected?.role !== "owner"
}
member={selected}
onChanged={() => void load()}
onChanged={reload}
onOpenChange={(o) => !o && setSelected(null)}
onRemove={(m) => {
setSelected(null);
@@ -39,12 +39,18 @@ export function PatientPortalSection() {
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
// Drop the previous clinic's QR as soon as the slug changes, rather than
// leaving it on screen until the new one arrives. Adjusted during render, so
// switching clinics never shows the old clinic's pairing code.
const [prevSlug, setPrevSlug] = useState(slug);
if (prevSlug !== slug) {
setPrevSlug(slug);
setQrUri("");
}
// Fetch the relay-based pairing descriptor for the QR (non-secret).
useEffect(() => {
if (!slug) {
setQrUri("");
return;
}
if (!slug) return;
let active = true;
getPortalLink(slug)
.then((link) => {
@@ -113,13 +113,17 @@ export function ProfilePanel() {
};
}, []);
// Seed the display name from the session once it loads.
useEffect(() => {
// Seed the display name from the session once it loads. Adjusted during
// render so the field isn't briefly empty after the session resolves. Still
// `prev ||`, so it never clobbers something the clinician has typed.
const [prevUserName, setPrevUserName] = useState(user?.name);
if (prevUserName !== user?.name) {
setPrevUserName(user?.name);
if (user?.name) {
setName((prev) => prev || user.name);
setBaselineName((prev) => prev || user.name);
}
}, [user?.name]);
}
const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value }));
+12 -4
View File
@@ -103,18 +103,26 @@ function AddTaskDialog({
const [priority, setPriority] = useState<Priority>("medium");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
// Re-seed the column when the dialog is opened from a specific column, and
// load the clinic's providers so a task can be assigned to a specific person.
// Re-seed the column when the dialog is opened from a specific column.
// Adjusted during render rather than in an effect, so the select never paints
// with the previous column's value.
const [prevSeed, setPrevSeed] = useState<string | null>(null);
const seed = open ? initialStatus : null;
if (prevSeed !== seed) {
setPrevSeed(seed);
if (seed) setStatus(seed);
}
// Load the clinic's providers so a task can be assigned to a specific person.
useEffect(() => {
if (!open) return;
setStatus(initialStatus);
listProviders()
.then((list) => {
setProviders(list);
setProviderId((id) => id || (list[0]?.userId ?? ""));
})
.catch(() => setProviders([]));
}, [open, initialStatus]);
}, [open]);
const reset = () => {
setTitle("");
+4 -1
View File
@@ -125,7 +125,10 @@ interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
}
function TimelineIndicator({
asChild = false,
// Accepted for API parity but unused: COSS/Base UI composes with `render`,
// not `asChild`. Destructured so it never lands on the DOM node.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
asChild: _asChild = false,
className,
children,
...props
+11 -5
View File
@@ -28,15 +28,21 @@ export function useWalletSync(fileNumber: string | null | undefined) {
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [error, setError] = useState<string | null>(null);
// Drop the previous patient's link state as soon as the selection changes,
// adjusted during render: `linked` gates the wallet step, so carrying it over
// for even one render would offer to push another patient's record.
const [prevFileNumber, setPrevFileNumber] = useState(fileNumber);
if (prevFileNumber !== fileNumber) {
setPrevFileNumber(fileNumber);
setLinked(false);
setChecking(Boolean(fileNumber));
}
// Resolve link status whenever the chosen patient changes. A 404 simply means
// "not wallet-backed", so failures collapse to `linked = false`.
useEffect(() => {
if (!fileNumber) {
setLinked(false);
return;
}
if (!fileNumber) return;
let active = true;
setChecking(true);
getWalletLink(fileNumber)
.then(() => {
if (active) setLinked(true);
+20
View File
@@ -12,6 +12,26 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Vendored component libraries we don't hand-maintain. Both are pulled in
// from upstream registries and re-linting them just reports upstream's
// style back at us — `react-hooks/refs` and `set-state-in-effect` alone
// account for most of it, and "fixing" them means diverging from upstream
// and eating the conflicts on every update.
//
// ai-elements additionally has pre-existing Base UI type drift already
// carved out via typescript.ignoreBuildErrors in next.config.ts; this is
// the lint half of that same carve-out. See CLAUDE.md.
"components/ai-elements/**",
"components/charts/**",
// Registry components, added/updated via `npx shadcn add` rather than
// hand-written. Two carry upstream patterns the compiler rules dislike
// (carousel publishes its api to the parent from an effect; the sidebar
// skeleton picks a random width), and editing them here would be undone by
// the next registry update.
"components/ui/carousel.tsx",
"components/ui/sidebar.tsx",
]),
]);
+18 -13
View File
@@ -2,18 +2,23 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
function subscribe(onChange: () => void) {
const mql = window.matchMedia(QUERY)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}
// `useSyncExternalStore` rather than an effect that seeds state on mount: the
// viewport is an external store, and reading it through the subscription keeps
// the value correct on the first paint instead of rendering one frame at the
// wrong breakpoint. The server snapshot is `false` (desktop-first) because
// there's no viewport to measure during SSR.
export function useIsMobile() {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false,
)
}