mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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[]>();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user