import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ApiErrorCode, apiErrorCode, apiQueryClient, invalidateResourceAccess, isApiErrorCode, type paths, useAuthenticatedQueries, } from "@/shared/api"; import { type InvestmentScreen, useTbankSync } from "./sync"; type ResourceMeta = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; type ScreenResource = { meta?: ResourceMeta; observe: (version?: string) => Promise; queryKey: QueryKey; transport: { fetchStatus: "fetching" | "idle" | "paused"; status: "error" | "pending" | "success"; }; }; const STATUS_INTERVAL_MS = 1000; function observeResource( resource: ScreenResource, latest: Map, completedId: string | undefined, observed: Map, ) { if (!resource.meta) { if (!completedId || resource.transport.fetchStatus !== "idle") return; const key = `query:${JSON.stringify(resource.queryKey)}`; if (observed.get(key) === completedId) return; observed.set(key, completedId); void resource.observe().catch(() => {}); return; } const changed = [ resource.meta, ...(resource.meta.dependencies ?? []), ].flatMap((current) => { const notified = latest.get(current.resourceKey); return notified && notified.stateVersion !== current.stateVersion ? [notified] : []; }); if (changed.length === 0) return; const notification = JSON.stringify( changed.map(meta => [meta.resourceKey, meta.stateVersion]), ); if (observed.get(resource.meta.resourceKey) === notification) return; observed.set(resource.meta.resourceKey, notification); const own = changed.find( meta => meta.resourceKey === resource.meta?.resourceKey, ); void resource.observe(own?.stateVersion).catch(() => {}); } function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const mutation = useTbankSync(); const client = useQueryClient(); const deniedRef = useRef(new Set()); const scopeKey = JSON.stringify(scope); const [requested, setRequested] = useState(null); const requestInFlightRef = useRef(null); const currentScopeRef = useRef(scopeKey); useLayoutEffect(() => { currentScopeRef.current = scopeKey; }, [scopeKey]); const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; const observedRef = useRef(new Map()); const ids = [ ...new Set([ ...(requested?.scope === scopeKey ? [requested.id] : []), ...resources .flatMap(({ meta }) => meta ? [meta, ...(meta.dependencies ?? [])] : [], ) .flatMap(meta => meta?.sync.id && (meta.sync.state === "running" || meta.sync.state === "queued") ? [meta.sync.id] : [], ), ]), ]; const statuses = useAuthenticatedQueries( ids.map(id => ({ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { params: { path: { id } }, }), refetchInterval: (query: { state: { data?: { state: string }; error: unknown }; }) => !(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS, refetchOnMount: false, refetchOnWindowFocus: false, retry: false, })), ); useEffect(() => { for (const status of statuses) { if (!status.error || deniedRef.current.has(status.error)) { continue; } deniedRef.current.add(status.error); if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) invalidateResourceAccess(client, "brokerage", status.error); } const latest = new Map(); const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt); for (const status of orderedStatuses) { const statusResources = status.data?.resources ?? []; for (const meta of statusResources) { latest.set(meta.resourceKey, meta); const dependencies = meta.dependencies ?? []; for (const dependency of dependencies) latest.set(dependency.resourceKey, dependency); } } const completed = statuses.find(status => requested?.scope === scopeKey && status.data?.id === requested.id && status.data.state !== "running")?.data; for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current); }, [client, statuses, resources, requested, scopeKey]); const isRefreshing = (isCurrentMutation && mutation.isPending) || statuses.some( status => !status.isError && status.data?.state === "running", ) || resources.some(({ meta }) => { const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; return ( !status?.isError && (!status?.data || status.data.state === "running") && (meta?.sync.state === "running" || meta?.sync.state === "queued") ); }); const refresh = async () => { if (isRefreshing || requestInFlightRef.current === scopeKey) return; requestInFlightRef.current = scopeKey; try { const accepted = await mutation.mutateAsync({ body: scope }); if ( currentScopeRef.current === scopeKey && requestInFlightRef.current === scopeKey ) setRequested({ id: accepted.id, scope: scopeKey }); } catch { /* The mutation retains the transport error for presentation. */ } finally { if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; } }; // Retry failed HTTP reads without asking the source to import again. // A source failure (or explicit refresh with no read error) still needs a sync. const retry = async () => { const failed = resources.filter( resource => resource.transport.status === "error", ); const failedStatuses = statuses.filter(status => status.isError); if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled([ ...failed.map(resource => resource.observe()), ...failedStatuses.map(status => status.refetch()), ]); } else await refresh(); }; return { refresh, refreshing: isRefreshing, retry, sourceError: statuses.some(status => status.data?.state === "failed") || resources.some(({ meta }) => meta?.sync.state === "failed"), transportError: (isCurrentMutation && mutation.isError) || statuses.some(status => status.isError), }; } export { useScreenSync };