chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
apiErrorCode,
|
||||
apiQueryClient,
|
||||
@@ -7,37 +8,74 @@ import {
|
||||
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 ResourceMeta
|
||||
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||
type ScreenResource = {
|
||||
queryKey: QueryKey;
|
||||
meta?: ResourceMeta;
|
||||
transport: {
|
||||
status: "pending" | "success" | "error";
|
||||
fetchStatus: "idle" | "fetching" | "paused";
|
||||
};
|
||||
observe: (version?: string) => Promise<unknown>;
|
||||
queryKey: QueryKey;
|
||||
transport: {
|
||||
fetchStatus: "fetching" | "idle" | "paused";
|
||||
status: "error" | "pending" | "success";
|
||||
};
|
||||
};
|
||||
const STATUS_INTERVAL_MS = 1000;
|
||||
|
||||
function observeResource(
|
||||
resource: ScreenResource,
|
||||
latest: Map<string, ResourceMeta>,
|
||||
completedId: string | undefined,
|
||||
observed: Map<string, string>,
|
||||
) {
|
||||
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 denied = useRef(new Set<unknown>());
|
||||
const deniedRef = useRef(new Set<unknown>());
|
||||
const scopeKey = JSON.stringify(scope);
|
||||
const [requested, setRequested] = useState<{
|
||||
scope: string;
|
||||
const [requested, setRequested] = useState<null | {
|
||||
id: string;
|
||||
} | null>(null);
|
||||
const requestInFlight = useRef<string | null>(null);
|
||||
const currentScope = useRef(scopeKey);
|
||||
scope: string;
|
||||
}>(null);
|
||||
const requestInFlightRef = useRef<null | string>(null);
|
||||
const currentScopeRef = useRef(scopeKey);
|
||||
useLayoutEffect(() => {
|
||||
currentScope.current = scopeKey;
|
||||
currentScopeRef.current = scopeKey;
|
||||
}, [scopeKey]);
|
||||
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
const observed = useRef(new Map<string, string>());
|
||||
const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||
const observedRef = useRef(new Map<string, string>());
|
||||
const ids = [
|
||||
...new Set([
|
||||
...(requested?.scope === scopeKey ? [requested.id] : []),
|
||||
@@ -45,139 +83,111 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
||||
.flatMap(({ meta }) =>
|
||||
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
||||
)
|
||||
.flatMap((meta) =>
|
||||
meta?.sync.id &&
|
||||
(meta.sync.state === "running" || meta.sync.state === "queued")
|
||||
.flatMap(meta =>
|
||||
meta?.sync.id
|
||||
&& (meta.sync.state === "running" || meta.sync.state === "queued")
|
||||
? [meta.sync.id]
|
||||
: [],
|
||||
),
|
||||
]),
|
||||
];
|
||||
const statuses = useAuthenticatedQueries(
|
||||
ids.map((id) => ({
|
||||
ids.map(id => ({
|
||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
||||
params: { path: { id } },
|
||||
}),
|
||||
retry: false,
|
||||
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,
|
||||
refetchInterval: (query: {
|
||||
state: { error: unknown; data?: { state: string } };
|
||||
}) =>
|
||||
query.state.error ||
|
||||
(query.state.data && query.state.data.state !== "running")
|
||||
? false
|
||||
: STATUS_INTERVAL_MS,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
useEffect(() => {
|
||||
for (const status of statuses) {
|
||||
if (status.error && !denied.current.has(status.error)) {
|
||||
denied.current.add(status.error);
|
||||
if (apiErrorCode(status.error) !== "NOT_FOUND")
|
||||
invalidateResourceAccess(client, "brokerage", status.error);
|
||||
if (!status.error || deniedRef.current.has(status.error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deniedRef.current.add(status.error);
|
||||
if (apiErrorCode(status.error) !== "NOT_FOUND")
|
||||
invalidateResourceAccess(client, "brokerage", status.error);
|
||||
}
|
||||
const latest = new Map<string, ResourceMeta>();
|
||||
for (const status of [...statuses].sort(
|
||||
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
|
||||
))
|
||||
for (const meta of status.data?.resources ?? []) {
|
||||
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);
|
||||
for (const dependency of meta.dependencies ?? [])
|
||||
const dependencies = meta.dependencies ?? [];
|
||||
for (const dependency of dependencies)
|
||||
latest.set(dependency.resourceKey, dependency);
|
||||
}
|
||||
for (const resource of resources) {
|
||||
if (!resource.meta) {
|
||||
if (resource.transport.fetchStatus !== "idle") continue;
|
||||
const completed = statuses.find(
|
||||
(status) =>
|
||||
requested?.scope === scopeKey &&
|
||||
status.data?.id === requested.id &&
|
||||
status.data.state !== "running",
|
||||
)?.data;
|
||||
const key = `query:${JSON.stringify(resource.queryKey)}`;
|
||||
if (completed && observed.current.get(key) !== completed.id) {
|
||||
observed.current.set(key, completed.id);
|
||||
void resource.observe().catch(() => undefined);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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) continue;
|
||||
const notification = JSON.stringify(
|
||||
changed.map((meta) => [meta.resourceKey, meta.stateVersion]),
|
||||
);
|
||||
if (observed.current.get(resource.meta.resourceKey) === notification)
|
||||
continue;
|
||||
observed.current.set(resource.meta.resourceKey, notification);
|
||||
const own = changed.find(
|
||||
(meta) => meta.resourceKey === resource.meta?.resourceKey,
|
||||
);
|
||||
void resource.observe(own?.stateVersion).catch(() => undefined);
|
||||
}
|
||||
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 refreshing =
|
||||
(currentMutation && 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 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 (requestInFlight.current === scopeKey || refreshing) return;
|
||||
requestInFlight.current = scopeKey;
|
||||
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
|
||||
requestInFlightRef.current = scopeKey;
|
||||
try {
|
||||
const accepted = await mutation.mutateAsync({ body: scope });
|
||||
if (
|
||||
currentScope.current === scopeKey &&
|
||||
requestInFlight.current === scopeKey
|
||||
currentScopeRef.current === scopeKey
|
||||
&& requestInFlightRef.current === scopeKey
|
||||
)
|
||||
setRequested({ scope: scopeKey, id: accepted.id });
|
||||
} catch {
|
||||
/* The mutation retains the transport error for presentation. */
|
||||
} finally {
|
||||
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
|
||||
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",
|
||||
resource => resource.transport.status === "error",
|
||||
);
|
||||
const failedStatuses = statuses.filter((status) => status.isError);
|
||||
if (failed.length || failedStatuses.length) {
|
||||
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()),
|
||||
...failed.map(resource => resource.observe()),
|
||||
...failedStatuses.map(status => status.refetch()),
|
||||
]);
|
||||
} else await refresh();
|
||||
}
|
||||
else await refresh();
|
||||
};
|
||||
return {
|
||||
refresh,
|
||||
refreshing: isRefreshing,
|
||||
retry,
|
||||
refreshing,
|
||||
transportError:
|
||||
(currentMutation && mutation.isError) ||
|
||||
statuses.some((status) => status.isError),
|
||||
sourceError:
|
||||
statuses.some((status) => status.data?.state === "failed") ||
|
||||
resources.some(({ meta }) => meta?.sync.state === "failed"),
|
||||
statuses.some(status => status.data?.state === "failed")
|
||||
|| resources.some(({ meta }) => meta?.sync.state === "failed"),
|
||||
transportError:
|
||||
(isCurrentMutation && mutation.isError)
|
||||
|| statuses.some(status => status.isError),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user