feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -0,0 +1,148 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
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<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const client = useQueryClient();
const denied = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<{
scope: string;
id: string;
} | null>(null);
const requestInFlight = useRef<string | null>(null);
const currentScope = useRef(scopeKey);
currentScope.current = scopeKey;
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
const observed = useRef(new Map<string, string>());
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 } },
}),
retry: false,
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,
})),
);
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);
}
}
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 ?? []) {
latest.set(meta.resourceKey, meta);
for (const dependency of meta.dependencies ?? [])
latest.set(dependency.resourceKey, dependency);
}
for (const resource of resources) {
if (!resource.meta) 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);
}
}, [client, statuses, resources]);
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 refresh = async () => {
if (requestInFlight.current === scopeKey || refreshing) return;
requestInFlight.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScope.current === scopeKey &&
requestInFlight.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;
}
};
return {
refresh,
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"),
};
}
export { useScreenSync };