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:
@@ -16,6 +16,7 @@ import type { IncomeAnalytics } from "@/entities/income";
|
|||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ beforeEach(() => {
|
|||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
accounts.map((account) => ({
|
accounts.map((account) => ({
|
||||||
...account,
|
...account,
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
@@ -140,13 +142,15 @@ beforeEach(() => {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(portfolio),
|
HttpResponse.json(resource(portfolio)),
|
||||||
),
|
),
|
||||||
http.get(
|
http.get(
|
||||||
"*/api/v1/brokerage-accounts/:accountId/operations",
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
({ params }) =>
|
({ params }) =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
items,
|
items,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
@@ -154,11 +158,13 @@ beforeEach(() => {
|
|||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
|
||||||
HttpResponse.json(analytics),
|
HttpResponse.json(resource(analytics)),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
calculatedAt: receipt.occurredAt,
|
calculatedAt: receipt.occurredAt,
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotalsByCurrency: [],
|
||||||
@@ -177,6 +183,7 @@ beforeEach(() => {
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -224,7 +231,8 @@ describe("All accounts routes", () => {
|
|||||||
({ request, params }) => {
|
({ request, params }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -237,7 +245,8 @@ describe("All accounts routes", () => {
|
|||||||
hasNext: !url.searchParams.has("cursor"),
|
hasNext: !url.searchParams.has("cursor"),
|
||||||
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
nextCursor: url.searchParams.has("cursor") ? null : "next",
|
||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -274,7 +283,8 @@ describe("All accounts routes", () => {
|
|||||||
{ code: "INVALID_OPERATIONS_CURSOR" },
|
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
: HttpResponse.json({
|
: HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
items,
|
items,
|
||||||
hasNext: true,
|
hasNext: true,
|
||||||
@@ -282,6 +292,7 @@ describe("All accounts routes", () => {
|
|||||||
observedAt: receipt.occurredAt,
|
observedAt: receipt.occurredAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
|
||||||
await screen.findAllByText("Выплата купона");
|
await screen.findAllByText("Выплата купона");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MemoryRouter, useNavigate } from "react-router";
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { ProtectedDocumentTitle } from "./DocumentTitle";
|
import { ProtectedDocumentTitle } from "./DocumentTitle";
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ describe("document titles", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", async () => {
|
http.get("*/api/v1/brokerage-accounts", async () => {
|
||||||
await accountsReady;
|
await accountsReady;
|
||||||
return HttpResponse.json([{ id: "a", name: "ИИС" }]);
|
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
|
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
|
||||||
@@ -85,7 +86,7 @@ describe("document titles", () => {
|
|||||||
async (kind, id, fallback, name) => {
|
async (kind, id, fallback, name) => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
|
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
|
||||||
HttpResponse.json({ name }),
|
HttpResponse.json(resource({ name })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderTitle(`/${kind}/${id}`);
|
renderTitle(`/${kind}/${id}`);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
type BondPeriod = NonNullable<
|
type BondPeriod = NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
@@ -13,7 +9,7 @@ type BondPeriod = NonNullable<
|
|||||||
const BOND_STALE_TIME_MS = 30_000;
|
const BOND_STALE_TIME_MS = 30_000;
|
||||||
|
|
||||||
function useBondSearch(query: string) {
|
function useBondSearch(query: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
|
||||||
params: { query: { query } },
|
params: { query: { query } },
|
||||||
}),
|
}),
|
||||||
@@ -36,11 +32,11 @@ function useBond(identifier: string) {
|
|||||||
identifier,
|
identifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUid = useAuthenticatedQuery({
|
const byUid = useResourceQuery({
|
||||||
...bondQueryOptions(identifier),
|
...bondQueryOptions(identifier),
|
||||||
enabled: Boolean(identifier) && isUid,
|
enabled: Boolean(identifier) && isUid,
|
||||||
});
|
});
|
||||||
const byTicker = useAuthenticatedQuery({
|
const byTicker = useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
|
||||||
params: { path: { ticker: identifier } },
|
params: { path: { ticker: identifier } },
|
||||||
}),
|
}),
|
||||||
@@ -50,7 +46,7 @@ function useBond(identifier: string) {
|
|||||||
return isUid ? byUid : byTicker;
|
return isUid ? byUid : byTicker;
|
||||||
}
|
}
|
||||||
function useBondMetrics(uid: string) {
|
function useBondMetrics(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -59,7 +55,7 @@ function useBondMetrics(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useBondQuote(uid: string) {
|
function useBondQuote(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -68,7 +64,7 @@ function useBondQuote(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useBondCandles(uid: string, period: BondPeriod) {
|
function useBondCandles(uid: string, period: BondPeriod) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
|
||||||
params: { path: { uid }, query: { period } },
|
params: { path: { uid }, query: { period } },
|
||||||
}),
|
}),
|
||||||
@@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useBondPosition(uid: string) {
|
function useBondPosition(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -86,7 +83,8 @@ function useBondPosition(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useBondCoupons(uid: string) {
|
function useBondCoupons(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
|
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
type OperationsEndpoint =
|
type OperationsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
||||||
|
|
||||||
type BrokerageOperation =
|
type BrokerageOperation = NonNullable<
|
||||||
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
|
OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
|
||||||
type BrokerageOperationsQuery = NonNullable<
|
>["items"][number];
|
||||||
OperationsEndpoint["parameters"]["query"]
|
type BrokerageOperationsQuery = Omit<
|
||||||
|
NonNullable<OperationsEndpoint["parameters"]["query"]>,
|
||||||
|
"sync"
|
||||||
>;
|
>;
|
||||||
type OperationCategory = BrokerageOperation["category"];
|
type OperationCategory = BrokerageOperation["category"];
|
||||||
|
|
||||||
@@ -56,7 +54,8 @@ function useBrokerageOperations(
|
|||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
query: BrokerageOperationsQuery = {},
|
query: BrokerageOperationsQuery = {},
|
||||||
) {
|
) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...brokerageOperationsQueryOptions(accountId, query),
|
...brokerageOperationsQueryOptions(accountId, query),
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ describe("useBrokerageAccounts", () => {
|
|||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
HttpResponse.json([
|
HttpResponse.json(
|
||||||
|
resource([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
@@ -24,6 +26,7 @@ describe("useBrokerageAccounts", () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
const { result } = renderHook(() => useBrokerageAccounts(), {
|
||||||
@@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => {
|
|||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1
|
return calls === 1
|
||||||
? HttpResponse.json([
|
? HttpResponse.json(
|
||||||
|
resource([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
openedOn: "2024-08-13",
|
openedOn: "2024-08-13",
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
|
)
|
||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
const brokerageAccountsQueryOptions = () =>
|
const brokerageAccountsQueryOptions = () =>
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
||||||
|
|
||||||
type BrokerageAccount =
|
type BrokerageAccount = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>[number];
|
||||||
|
|
||||||
function useBrokerageAccounts() {
|
function useBrokerageAccounts() {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...brokerageAccountsQueryOptions(),
|
...brokerageAccountsQueryOptions(),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
import type {
|
||||||
|
BrokerageAccount,
|
||||||
|
useBrokerageAccounts,
|
||||||
|
} from "../api/useBrokerageAccounts";
|
||||||
|
|
||||||
type Money = NonNullable<
|
type Money = NonNullable<
|
||||||
BrokerageAccount["portfolioSummary"]
|
BrokerageAccount["portfolioSummary"]
|
||||||
>["totalsByCurrency"][number];
|
>["totalsByCurrency"][number];
|
||||||
|
|
||||||
type BrokerageAccountCard = {
|
type BrokerageAccountCard = {
|
||||||
|
source?: NonNullable<
|
||||||
|
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||||
|
>["dependencies"] extends (infer T)[] | undefined
|
||||||
|
? T
|
||||||
|
: never;
|
||||||
account: BrokerageAccount;
|
account: BrokerageAccount;
|
||||||
positionCounts: { stocks: number; bonds: number } | null;
|
positionCounts: { stocks: number; bonds: number } | null;
|
||||||
summary:
|
summary:
|
||||||
@@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
|||||||
|
|
||||||
function brokerageAccountCards(
|
function brokerageAccountCards(
|
||||||
accounts: BrokerageAccount[],
|
accounts: BrokerageAccount[],
|
||||||
|
dependencies: NonNullable<
|
||||||
|
ReturnType<typeof useBrokerageAccounts>["meta"]
|
||||||
|
>["dependencies"] = [],
|
||||||
): BrokerageAccountCard[] {
|
): BrokerageAccountCard[] {
|
||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
|
const source = dependencies.find(
|
||||||
|
(item) =>
|
||||||
|
item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
|
||||||
|
);
|
||||||
const summary = account.portfolioSummary;
|
const summary = account.portfolioSummary;
|
||||||
if (!summary)
|
if (!summary)
|
||||||
return {
|
return {
|
||||||
|
source,
|
||||||
account,
|
account,
|
||||||
positionCounts: null,
|
positionCounts: null,
|
||||||
summary: { state: "unavailable" },
|
summary: { state: "unavailable" },
|
||||||
@@ -33,8 +49,9 @@ function brokerageAccountCards(
|
|||||||
bonds: summary.bondPositionCount,
|
bonds: summary.bondPositionCount,
|
||||||
};
|
};
|
||||||
if (!summary.totalsByCurrency.length)
|
if (!summary.totalsByCurrency.length)
|
||||||
return { account, positionCounts, summary: { state: "empty" } };
|
return { source, account, positionCounts, summary: { state: "empty" } };
|
||||||
return {
|
return {
|
||||||
|
source,
|
||||||
account,
|
account,
|
||||||
positionCounts,
|
positionCounts,
|
||||||
summary: {
|
summary: {
|
||||||
|
|||||||
+41
@@ -185,3 +185,44 @@ export const LargeValues: Story = {
|
|||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const IndependentFreshness: Story = {
|
||||||
|
name: "Независимая свежесть и ошибка сводок",
|
||||||
|
args: {
|
||||||
|
accounts: brokerageAccountCards(
|
||||||
|
[
|
||||||
|
syntheticAccount,
|
||||||
|
{
|
||||||
|
...syntheticAccount,
|
||||||
|
id: "waiting",
|
||||||
|
name: "Новый счёт",
|
||||||
|
portfolioSummary: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
resourceKey: "portfolio/storybook-account",
|
||||||
|
dataRevision: "1",
|
||||||
|
stateVersion: "failed",
|
||||||
|
availability: "complete",
|
||||||
|
freshness: "stale",
|
||||||
|
lastSuccessfulAt: "2026-09-24T10:30:00Z",
|
||||||
|
sync: {
|
||||||
|
id: "failed",
|
||||||
|
state: "failed",
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceKey: "portfolio/waiting",
|
||||||
|
dataRevision: "0",
|
||||||
|
stateVersion: "waiting",
|
||||||
|
availability: "missing",
|
||||||
|
freshness: "unknown",
|
||||||
|
lastSuccessfulAt: null,
|
||||||
|
sync: { id: "waiting", state: "running", errorCode: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { brokerageAccountCards } from "../../model/display";
|
import { brokerageAccountCards } from "../../model/display";
|
||||||
import { BrokerageAccountList } from "./BrokerageAccountList";
|
import { BrokerageAccountList } from "./BrokerageAccountList";
|
||||||
|
|
||||||
@@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows portfolio summary freshness independently for each account", () => {
|
||||||
|
const accounts = brokerageAccountCards(
|
||||||
|
["fresh", "stale"].map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
type: "BROKERAGE" as const,
|
||||||
|
openedOn: null,
|
||||||
|
portfolioSummary: {
|
||||||
|
stockPositionCount: 1,
|
||||||
|
bondPositionCount: 0,
|
||||||
|
totalsByCurrency: [{ currency: "RUB", value: "10" }],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" })
|
||||||
|
.meta,
|
||||||
|
resource(null, { resourceKey: "portfolio/stale", freshness: "stale" })
|
||||||
|
.meta,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<BrokerageAccountList accounts={accounts} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("link", { name: /stale/ })).getByText(
|
||||||
|
"Данные требуют обновления",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
|
||||||
|
"Данные требуют обновления",
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { formatDate, formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { BrokerageAccountCard } from "../../model/display";
|
import type { BrokerageAccountCard } from "../../model/display";
|
||||||
import { accountTypeKey } from "../../model/display";
|
import { accountTypeKey } from "../../model/display";
|
||||||
import classes from "./BrokerageAccountList.module.css";
|
import classes from "./BrokerageAccountList.module.css";
|
||||||
@@ -52,8 +52,8 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg">
|
<SimpleGrid className={classes.list} cols={1} spacing="lg">
|
||||||
{accounts.map(({ account, summary, positionCounts }) => (
|
{accounts.map(({ account, summary, positionCounts, source }) => (
|
||||||
<Card
|
<Card
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/accounts/${account.id}`}
|
to={`/accounts/${account.id}`}
|
||||||
@@ -92,6 +92,25 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
{t("accounts.currentValue")}
|
{t("accounts.currentValue")}
|
||||||
</Text>
|
</Text>
|
||||||
<BrokerageAccountSummary summary={summary} />
|
<BrokerageAccountSummary summary={summary} />
|
||||||
|
{source?.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(source.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{source?.sync.state === "running" ||
|
||||||
|
source?.sync.state === "queued" ? (
|
||||||
|
<Text size="xs" role="status">
|
||||||
|
{t("accounts.refreshing")}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{source?.sync.state === "failed" ? (
|
||||||
|
<Text size="xs">{t("accounts.refreshError")}</Text>
|
||||||
|
) : null}
|
||||||
|
{source?.freshness === "stale" ? (
|
||||||
|
<Text size="xs">{t("investment.stale")}</Text>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
{positionCounts ? (
|
{positionCounts ? (
|
||||||
<dl className={classes.positions}>
|
<dl className={classes.positions}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useBrokerageEvents } from "./useBrokerageEvents";
|
import { useBrokerageEvents } from "./useBrokerageEvents";
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ describe("useBrokerageEvents", () => {
|
|||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
events: [
|
events: [
|
||||||
@@ -49,6 +51,7 @@ describe("useBrokerageEvents", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
|
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
type EventsEndpoint =
|
type EventsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
||||||
type BrokerageEvent =
|
type BrokerageEvent = NonNullable<
|
||||||
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>["events"][number];
|
||||||
|
|
||||||
function useBrokerageEvents(
|
function useBrokerageEvents(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
query: EventsEndpoint["parameters"]["query"] = {},
|
query: Omit<NonNullable<EventsEndpoint["parameters"]["query"]>, "sync"> = {},
|
||||||
) {
|
) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/events",
|
"/api/v1/brokerage-accounts/{accountId}/events",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||||
import { BrokerageEventCard } from "../BrokerageEventCard";
|
import { BrokerageEventCard } from "../BrokerageEventCard";
|
||||||
|
|
||||||
@@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = {
|
|||||||
|
|
||||||
type BrokerageEventsPreviewState =
|
type BrokerageEventsPreviewState =
|
||||||
| { state: "loading" | "error" | "empty"; to: string }
|
| { state: "loading" | "error" | "empty"; to: string }
|
||||||
| { state: "loaded"; to: string; items: BrokerageEvent[] };
|
| {
|
||||||
|
state: "loaded" | "partial";
|
||||||
|
to: string;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
stale?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
items: BrokerageEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
||||||
const { events } = props;
|
const { events } = props;
|
||||||
@@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{events.state === "loaded" ? (
|
||||||
|
<>
|
||||||
|
{events.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(events.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||||
|
{events.refreshError ? (
|
||||||
|
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{events.state === "loading" ? (
|
{events.state === "loading" ? (
|
||||||
<LoadingSkeleton label={t("events.recentLoading")}>
|
<LoadingSkeleton label={t("events.recentLoading")}>
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
@@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
{events.state === "empty" ? (
|
{events.state === "empty" ? (
|
||||||
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
<Text c="dimmed">{t("events.recentEmpty")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{events.state === "loaded"
|
{events.state === "partial" ? (
|
||||||
|
<Text role="status">{t("investment.partial")}</Text>
|
||||||
|
) : null}
|
||||||
|
{events.state === "loaded" || events.state === "partial"
|
||||||
? events.items.map((event) => (
|
? events.items.map((event) => (
|
||||||
<BrokerageEventCard
|
<BrokerageEventCard
|
||||||
key={JSON.stringify([
|
key={JSON.stringify([
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
type IncomeSummary =
|
type IncomeSummary = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
type IncomeAnalytics =
|
>;
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"];
|
type IncomeAnalytics = NonNullable<
|
||||||
|
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>;
|
||||||
|
|
||||||
function useIncomeSummary(accountId: string | undefined) {
|
function useIncomeSummary(accountId: string | undefined) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/income-summary",
|
"/api/v1/brokerage-accounts/{accountId}/income-summary",
|
||||||
@@ -25,7 +24,8 @@ function useIncomeAnalytics(
|
|||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
period: { from: string; to: string },
|
period: { from: string; to: string },
|
||||||
) {
|
) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts/{accountId}/income-analytics",
|
"/api/v1/brokerage-accounts/{accountId}/income-analytics",
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import {
|
||||||
|
type Portfolio,
|
||||||
|
portfolioQueryOptions,
|
||||||
|
usePortfolio,
|
||||||
|
} from "./usePortfolio";
|
||||||
|
|
||||||
|
const snapshot: Portfolio = {
|
||||||
|
positions: [],
|
||||||
|
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
|
||||||
|
cashBalances: [],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
|
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||||
|
isStale: false,
|
||||||
|
};
|
||||||
|
function auth() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "synthetic",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("persistent resource query lifecycle", () => {
|
||||||
|
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
|
||||||
|
auth();
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
let observations = 0;
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||||
|
({ request }) => {
|
||||||
|
const mode = new URL(request.url).searchParams.get("sync");
|
||||||
|
modes.push(mode);
|
||||||
|
if (mode) observations += 1;
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(snapshot, {
|
||||||
|
stateVersion: observations > 1 ? "new" : "old",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
await act(() => result.current.observe("new"));
|
||||||
|
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
|
||||||
|
expect(modes).toEqual([null, "observe", "observe"]);
|
||||||
|
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
|
||||||
|
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
|
||||||
|
});
|
||||||
|
it("does not convert missing or failed source data to empty financial success", async () => {
|
||||||
|
auth();
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(null, {
|
||||||
|
sync: {
|
||||||
|
id: "work",
|
||||||
|
state: "failed",
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => usePortfolio("a"), {
|
||||||
|
wrapper: createQueryWrapper().wrapper,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
|
||||||
|
expect(result.current.data).toBeUndefined();
|
||||||
|
expect(result.current.isSuccess).toBe(false);
|
||||||
|
expect(result.current.meta?.availability).toBe("missing");
|
||||||
|
});
|
||||||
|
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
|
||||||
|
auth();
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||||
|
async ({ request }) => {
|
||||||
|
if (!new URL(request.url).searchParams.has("sync")) await blocked;
|
||||||
|
return HttpResponse.json(resource(snapshot));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const old = apiClient.GET(
|
||||||
|
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||||
|
{ params: { path: { accountId: "a" } } },
|
||||||
|
);
|
||||||
|
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||||
|
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||||
|
});
|
||||||
|
release();
|
||||||
|
await rejected;
|
||||||
|
});
|
||||||
|
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||||
|
auth();
|
||||||
|
let denied = false;
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
||||||
|
async ({ params }) => {
|
||||||
|
if (params.accountId === "b") await blocked;
|
||||||
|
if (params.accountId === "a" && denied)
|
||||||
|
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
|
||||||
|
return HttpResponse.json(resource(snapshot));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { wrapper } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
|
||||||
|
denied = true;
|
||||||
|
await act(() => result.current.a.refetch());
|
||||||
|
await waitFor(() => expect(result.current.a.data).toBeUndefined());
|
||||||
|
await act(async () => {
|
||||||
|
release();
|
||||||
|
await blocked;
|
||||||
|
});
|
||||||
|
expect(result.current.b.data).toBeUndefined();
|
||||||
|
expect(result.current.a.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { usePortfolio } from "./usePortfolio";
|
import { usePortfolio } from "./usePortfolio";
|
||||||
|
|
||||||
@@ -33,14 +33,16 @@ describe("usePortfolio", () => {
|
|||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return calls === 1
|
return calls === 1
|
||||||
? HttpResponse.json({
|
? HttpResponse.json(
|
||||||
|
resource({
|
||||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import {
|
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
|
||||||
apiQueryClient,
|
|
||||||
type paths,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "@/shared/api";
|
|
||||||
|
|
||||||
const portfolioQueryPolicy = {
|
const portfolioQueryPolicy = {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Portfolio =
|
type Portfolio = NonNullable<
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>;
|
||||||
|
|
||||||
function portfolioQueryOptions(accountId: string | undefined) {
|
function portfolioQueryOptions(accountId: string | undefined) {
|
||||||
return apiQueryClient.queryOptions(
|
return apiQueryClient.queryOptions(
|
||||||
@@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePortfolio(accountId: string | undefined) {
|
function usePortfolio(accountId: string | undefined) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...portfolioQueryOptions(accountId),
|
...portfolioQueryOptions(accountId),
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
...portfolioQueryPolicy,
|
...portfolioQueryPolicy,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
type paths,
|
type paths,
|
||||||
|
unwrapResourceResult,
|
||||||
useAuthenticatedQueries,
|
useAuthenticatedQueries,
|
||||||
useAuthenticatedQuery,
|
useResourceQuery,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
|
|
||||||
type StockPeriod = NonNullable<
|
type StockPeriod = NonNullable<
|
||||||
@@ -14,7 +15,7 @@ type StockPeriod = NonNullable<
|
|||||||
const STOCK_STALE_TIME_MS = 30_000;
|
const STOCK_STALE_TIME_MS = 30_000;
|
||||||
|
|
||||||
function useStockSearch(query: string) {
|
function useStockSearch(query: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
|
||||||
params: { query: { query } },
|
params: { query: { query } },
|
||||||
}),
|
}),
|
||||||
@@ -35,7 +36,10 @@ function useStocks(uids: string[]) {
|
|||||||
const uniqueUids = [...new Set(uids)];
|
const uniqueUids = [...new Set(uids)];
|
||||||
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
|
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
|
||||||
return new Map(
|
return new Map(
|
||||||
queries.map((query, index) => [uniqueUids[index], query.data]),
|
queries.map((query, index) => [
|
||||||
|
uniqueUids[index],
|
||||||
|
unwrapResourceResult(query).data,
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +49,11 @@ function useStock(identifier: string) {
|
|||||||
identifier,
|
identifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byUid = useAuthenticatedQuery({
|
const byUid = useResourceQuery({
|
||||||
...stockQueryOptions(identifier),
|
...stockQueryOptions(identifier),
|
||||||
enabled: Boolean(identifier) && isUid,
|
enabled: Boolean(identifier) && isUid,
|
||||||
});
|
});
|
||||||
const byTicker = useAuthenticatedQuery({
|
const byTicker = useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
|
||||||
params: { path: { ticker: identifier } },
|
params: { path: { ticker: identifier } },
|
||||||
}),
|
}),
|
||||||
@@ -59,7 +63,7 @@ function useStock(identifier: string) {
|
|||||||
return isUid ? byUid : byTicker;
|
return isUid ? byUid : byTicker;
|
||||||
}
|
}
|
||||||
function useStockQuote(uid: string) {
|
function useStockQuote(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -68,7 +72,7 @@ function useStockQuote(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useStockCandles(uid: string, period: StockPeriod) {
|
function useStockCandles(uid: string, period: StockPeriod) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
|
||||||
params: { path: { uid }, query: { period } },
|
params: { path: { uid }, query: { period } },
|
||||||
}),
|
}),
|
||||||
@@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useStockPosition(uid: string) {
|
function useStockPosition(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -86,7 +91,7 @@ function useStockPosition(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useStockCompany(uid: string) {
|
function useStockCompany(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
@@ -95,7 +100,8 @@ function useStockCompany(uid: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function useStockDividends(uid: string) {
|
function useStockDividends(uid: string) {
|
||||||
return useAuthenticatedQuery({
|
return useResourceQuery({
|
||||||
|
meta: { accessGroup: "brokerage" },
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
|
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
|
||||||
params: { path: { uid } },
|
params: { path: { uid } },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { FC, PropsWithChildren } from "react";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AuthProvider, useAuth } from "./auth";
|
import { AuthProvider, useAuth } from "./auth";
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ describe("AuthProvider", () => {
|
|||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
authorization = request.headers.get("Authorization");
|
authorization = request.headers.get("Authorization");
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json(resource([]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
|||||||
refresh,
|
refresh,
|
||||||
clear: clearSession,
|
clear: clearSession,
|
||||||
});
|
});
|
||||||
return () => configureAuthClient(null);
|
|
||||||
});
|
});
|
||||||
|
useEffect(() => () => configureAuthClient(null), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext
|
<AuthContext
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
|
import { section } from "./section";
|
||||||
|
|
||||||
|
describe("instrument source and transport presentation", () => {
|
||||||
|
it.each(["missing", "partial", "complete"] as const)(
|
||||||
|
"keeps %s separate from successful transport",
|
||||||
|
(availability) => {
|
||||||
|
const data = availability === "missing" ? undefined : { rows: [] };
|
||||||
|
const state = section({
|
||||||
|
data,
|
||||||
|
meta: resource(data, { availability }).meta,
|
||||||
|
isError: false,
|
||||||
|
isFetching: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(state.loading).toBe(availability === "missing");
|
||||||
|
expect(state.partial).toBe(availability === "partial");
|
||||||
|
expect(state.hasData).toBe(availability !== "missing");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
|
||||||
|
const retry = vi.fn();
|
||||||
|
const refetch = vi.fn();
|
||||||
|
const meta = resource([], {
|
||||||
|
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||||
|
}).meta;
|
||||||
|
const state = section(
|
||||||
|
{ data: [], meta, isError: false, isFetching: false, refetch },
|
||||||
|
retry,
|
||||||
|
);
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
hasData: true,
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
refreshing: false,
|
||||||
|
lastSuccessfulAt: meta.lastSuccessfulAt,
|
||||||
|
});
|
||||||
|
state.retry();
|
||||||
|
expect(retry).toHaveBeenCalledOnce();
|
||||||
|
expect(refetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("shows source failure without a false loading or empty result", () => {
|
||||||
|
const state = section({
|
||||||
|
data: undefined,
|
||||||
|
meta: resource(null, {
|
||||||
|
sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
|
||||||
|
}).meta,
|
||||||
|
isError: false,
|
||||||
|
isFetching: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
hasData: false,
|
||||||
|
loading: false,
|
||||||
|
error: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,44 @@
|
|||||||
|
import type { paths } from "@/shared/api";
|
||||||
|
|
||||||
|
type Meta =
|
||||||
|
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
|
||||||
type SectionState = {
|
type SectionState = {
|
||||||
|
stale?: boolean;
|
||||||
|
sourceError?: boolean;
|
||||||
|
partial?: boolean;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
hasData: boolean;
|
hasData: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
};
|
};
|
||||||
function section(query: {
|
function section(
|
||||||
|
query: {
|
||||||
data: unknown;
|
data: unknown;
|
||||||
|
meta?: Meta;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
refetch: () => unknown;
|
refetch: () => unknown;
|
||||||
}): SectionState {
|
},
|
||||||
|
retry?: () => unknown,
|
||||||
|
): SectionState {
|
||||||
|
const error = query.isError || query.meta?.sync.state === "failed";
|
||||||
return {
|
return {
|
||||||
|
stale: query.meta?.freshness === "stale",
|
||||||
|
sourceError: query.meta?.sync.state === "failed",
|
||||||
|
partial: query.meta?.availability === "partial",
|
||||||
|
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
|
||||||
hasData: Boolean(query.data),
|
hasData: Boolean(query.data),
|
||||||
loading: !query.data && !query.isError,
|
loading: !query.data && !error,
|
||||||
error: query.isError,
|
error,
|
||||||
refreshing: Boolean(query.data) && query.isFetching,
|
refreshing:
|
||||||
|
Boolean(query.data) &&
|
||||||
|
(query.isFetching ||
|
||||||
|
query.meta?.sync.state === "running" ||
|
||||||
|
query.meta?.sync.state === "queued"),
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void query.refetch();
|
void (retry ?? query.refetch)();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import type { BrokerageOperation } from "@/entities/broker-operation";
|
|||||||
import type { paths } from "@/shared/api";
|
import type { paths } from "@/shared/api";
|
||||||
import type { SectionState } from "./section";
|
import type { SectionState } from "./section";
|
||||||
|
|
||||||
type Position =
|
type Position = NonNullable<
|
||||||
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
type Candles =
|
>;
|
||||||
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"];
|
type Candles = NonNullable<
|
||||||
|
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
|
||||||
|
>;
|
||||||
type ChartRow = Candles["candles"][number] & { date: string; price: number };
|
type ChartRow = Candles["candles"][number] & { date: string; price: number };
|
||||||
type InstrumentPositionVm = {
|
type InstrumentPositionVm = {
|
||||||
position?: Position;
|
position?: Position;
|
||||||
|
|||||||
@@ -177,7 +177,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed">{t("stock.noCandles")}</Text>
|
<Text c="dimmed">
|
||||||
|
{t(
|
||||||
|
vm.candlesState.partial
|
||||||
|
? "investment.partial"
|
||||||
|
: "stock.noCandles",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</InstrumentSection>
|
</InstrumentSection>
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed" py="lg">
|
<Text c="dimmed" py="lg">
|
||||||
{t("stock.noOperations")}
|
{t(
|
||||||
|
vm.operationsState.partial
|
||||||
|
? "investment.partial"
|
||||||
|
: "stock.noOperations",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { SectionState } from "../model/section";
|
import type { SectionState } from "../model/section";
|
||||||
import classes from "./InstrumentDetails.module.css";
|
import classes from "./InstrumentDetails.module.css";
|
||||||
|
|
||||||
@@ -38,20 +39,40 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
|||||||
</Title>
|
</Title>
|
||||||
{props.controls}
|
{props.controls}
|
||||||
</Group>
|
</Group>
|
||||||
|
{props.state.stale ? (
|
||||||
|
<Text size="xs">{t("investment.stale")}</Text>
|
||||||
|
) : null}
|
||||||
|
{props.state.partial ? (
|
||||||
|
<Text role="status">{t("investment.partial")}</Text>
|
||||||
|
) : null}
|
||||||
|
{props.state.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{props.state.refreshing ? (
|
{props.state.refreshing ? (
|
||||||
<Text role="status" size="xs" c="dimmed">
|
<Text role="status" size="xs" c="dimmed">
|
||||||
{t("accounts.refreshing")}
|
{t("accounts.refreshing")}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{props.state.error ? (
|
{props.state.error ? (
|
||||||
<Alert color="orange" mb="sm" role="alert">
|
<Alert color="orange" mb="sm" role="status" aria-live="polite">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{props.state.hasData
|
{props.state.sourceError
|
||||||
|
? t("investment.syncFailed")
|
||||||
|
: props.state.hasData
|
||||||
? t("accounts.refreshError")
|
? t("accounts.refreshError")
|
||||||
: t("stock.sectionError")}
|
: t("stock.sectionError")}
|
||||||
</Text>
|
</Text>
|
||||||
<Button size="xs" variant="default" onClick={props.state.retry}>
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="default"
|
||||||
|
onClick={props.state.retry}
|
||||||
|
disabled={props.state.refreshing}
|
||||||
|
>
|
||||||
{t("accounts.retry")}
|
{t("accounts.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
|
export { useScreenSync } from "./model/screen-sync";
|
||||||
|
export {
|
||||||
|
type InvestmentScreen,
|
||||||
|
useTbankSync,
|
||||||
|
useTbankSyncStatus,
|
||||||
|
} from "./model/sync";
|
||||||
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
|
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { apiQueryClient, type paths } from "@/shared/api";
|
import {
|
||||||
|
apiQueryClient,
|
||||||
|
invalidateApiRequests,
|
||||||
|
type paths,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
type TbankConnectionStatus =
|
type TbankConnectionStatus =
|
||||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
@@ -15,6 +19,13 @@ function commitTbankConnectionBoundary(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
status: TbankConnectionStatus,
|
status: TbankConnectionStatus,
|
||||||
) {
|
) {
|
||||||
|
invalidateApiRequests(
|
||||||
|
(path) =>
|
||||||
|
path.startsWith("/api/v1/brokerage-accounts") ||
|
||||||
|
path.startsWith("/api/v1/stocks") ||
|
||||||
|
path.startsWith("/api/v1/bonds") ||
|
||||||
|
path.startsWith("/api/v1/tbank-sync"),
|
||||||
|
);
|
||||||
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
queryClient.setQueryData(tbankConnectionQueryKey, status);
|
||||||
void queryClient.resetQueries({
|
void queryClient.resetQueries({
|
||||||
predicate: ({ queryKey }) => {
|
predicate: ({ queryKey }) => {
|
||||||
@@ -22,7 +33,8 @@ function commitTbankConnectionBoundary(
|
|||||||
return (
|
return (
|
||||||
method === "get" &&
|
method === "get" &&
|
||||||
typeof path === "string" &&
|
typeof path === "string" &&
|
||||||
(path === "/api/v1/bonds" ||
|
(path.startsWith("/api/v1/tbank-sync") ||
|
||||||
|
path === "/api/v1/bonds" ||
|
||||||
path.startsWith("/api/v1/bonds/") ||
|
path.startsWith("/api/v1/bonds/") ||
|
||||||
path === "/api/v1/stocks" ||
|
path === "/api/v1/stocks" ||
|
||||||
path.startsWith("/api/v1/stocks/") ||
|
path.startsWith("/api/v1/stocks/") ||
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import { useScreenSync } from "../index";
|
||||||
|
|
||||||
|
function auth() {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "synthetic",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
describe("page-owned screen synchronization", () => {
|
||||||
|
it("posts only its scope once and observes completion without importing again", async () => {
|
||||||
|
auth();
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
let completed = false;
|
||||||
|
let polls = 0;
|
||||||
|
const meta = () =>
|
||||||
|
resource([], { stateVersion: completed ? "2" : "1" }).meta;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return HttpResponse.json(resource([], meta()));
|
||||||
|
}),
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
bodies.push(await request.json());
|
||||||
|
completed = true;
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "screen",
|
||||||
|
scope: { screen: "accounts" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () => {
|
||||||
|
polls++;
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: "screen",
|
||||||
|
scope: { screen: "accounts" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 1, completed: 1, discovering: false },
|
||||||
|
resources: [meta()],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result, unmount } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
result.current.sync.refresh(),
|
||||||
|
result.current.sync.refresh(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
|
||||||
|
);
|
||||||
|
expect(bodies).toEqual([{ screen: "accounts" }]);
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(120000));
|
||||||
|
expect(polls).toBe(1);
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
unmount();
|
||||||
|
await vi.advanceTimersByTimeAsync(120000);
|
||||||
|
expect(polls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it.each(["leave", "logout"])(
|
||||||
|
"publishes completed empty coverage while history runs, then stops on %s",
|
||||||
|
async (boundary) => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
try {
|
||||||
|
auth();
|
||||||
|
let completed = false;
|
||||||
|
let polls = 0;
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
const snapshot = () =>
|
||||||
|
resource(completed ? [] : null, {
|
||||||
|
stateVersion: completed ? "covered" : "initial",
|
||||||
|
sync: { id: "history", state: "running", errorCode: null },
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||||
|
return HttpResponse.json(snapshot());
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () => {
|
||||||
|
polls++;
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: "history",
|
||||||
|
scope: null,
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "running",
|
||||||
|
errorCode: null,
|
||||||
|
progress: {
|
||||||
|
total: 2,
|
||||||
|
completed: completed ? 1 : 0,
|
||||||
|
discovering: true,
|
||||||
|
},
|
||||||
|
resources: [snapshot().meta],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result, unmount } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.accounts.meta?.sync.id).toBe("history"),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(polls).toBeGreaterThan(0));
|
||||||
|
expect(result.current.sync.refreshing).toBe(true);
|
||||||
|
expect(result.current.accounts.data).toBeUndefined();
|
||||||
|
completed = true;
|
||||||
|
vi.setSystemTime(Date.now() + 61000);
|
||||||
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
expect(result.current.sync.refreshing).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
});
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
if (boundary === "leave") unmount();
|
||||||
|
else await act(() => configureAuthClient(null));
|
||||||
|
const stopped = polls;
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
});
|
||||||
|
expect(polls).toBe(stopped);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("clears private snapshots when status confirms access loss", async () => {
|
||||||
|
auth();
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource([], {
|
||||||
|
sync: { id: "revoked", state: "running", errorCode: null },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||||
|
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
|
||||||
|
expect(result.current.accounts.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("observes a projection when only its dependency reports new coverage", async () => {
|
||||||
|
auth();
|
||||||
|
let observed = false;
|
||||||
|
const modes: (string | null)[] = [];
|
||||||
|
const dependency = resource([], {
|
||||||
|
resourceKey: "operations",
|
||||||
|
stateVersion: observed ? "covered" : "initial",
|
||||||
|
sync: {
|
||||||
|
id: "dependency",
|
||||||
|
state: observed ? "idle" : "running",
|
||||||
|
errorCode: null,
|
||||||
|
},
|
||||||
|
}).meta;
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||||
|
const mode = new URL(request.url).searchParams.get("sync");
|
||||||
|
modes.push(mode);
|
||||||
|
if (mode) observed = true;
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(observed ? [] : null, {
|
||||||
|
resourceKey: "projection",
|
||||||
|
dependencies: [
|
||||||
|
{ ...dependency, stateVersion: observed ? "covered" : "initial" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
id: "dependency",
|
||||||
|
scope: null,
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 1, completed: 1, discovering: false },
|
||||||
|
resources: [{ ...dependency, stateVersion: "covered" }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||||
|
return accounts;
|
||||||
|
},
|
||||||
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.data).toEqual([]));
|
||||||
|
expect(modes).toEqual([null, "observe"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
|
||||||
|
auth();
|
||||||
|
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
|
||||||
|
http.post("*/api/v1/tbank-sync", () =>
|
||||||
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => {
|
||||||
|
const accounts = useBrokerageAccounts();
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
sync: useScreenSync({ screen: "accounts" }, [accounts]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
|
||||||
|
await act(() => result.current.sync.refresh());
|
||||||
|
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
|
||||||
|
expect(result.current.accounts.meta).toEqual(saved.meta);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { server } from "@/test/server";
|
||||||
|
import {
|
||||||
|
type InvestmentScreen,
|
||||||
|
useTbankSync,
|
||||||
|
useTbankSyncStatus,
|
||||||
|
} from "../index";
|
||||||
|
import { commitTbankConnectionBoundary } from "./connection-boundary";
|
||||||
|
|
||||||
|
describe("sync connection and session boundaries", () => {
|
||||||
|
it.each(["connection", "session"])(
|
||||||
|
"discards pending status and mutation after %s changes",
|
||||||
|
async (boundary) => {
|
||||||
|
let generation = 0;
|
||||||
|
configureAuthClient({
|
||||||
|
generation: () => generation,
|
||||||
|
accessToken: () => "synthetic",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const accepted = {
|
||||||
|
id: "work",
|
||||||
|
scope: { screen: "accounts" },
|
||||||
|
now: "2026-09-25T12:00:00Z",
|
||||||
|
};
|
||||||
|
let started = 0;
|
||||||
|
let changed = false;
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", async () => {
|
||||||
|
started += 1;
|
||||||
|
await blocked;
|
||||||
|
return HttpResponse.json(accepted, { status: 202 });
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", async () => {
|
||||||
|
const now = changed ? "2026-09-25T12:01:00Z" : accepted.now;
|
||||||
|
started += 1;
|
||||||
|
await blocked;
|
||||||
|
return HttpResponse.json({
|
||||||
|
...accepted,
|
||||||
|
now,
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 1, completed: 1, discovering: false },
|
||||||
|
resources: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
|
const published: string[] = [];
|
||||||
|
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||||
|
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
|
||||||
|
const data = event.query.state.data as { now?: string } | undefined;
|
||||||
|
if (data?.now) published.push(data.now);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
mutation: useTbankSync(),
|
||||||
|
status: useTbankSyncStatus("work"),
|
||||||
|
}),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
const screen: InvestmentScreen = { screen: "accounts" };
|
||||||
|
let mutation!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
mutation = result.current.mutation.mutateAsync({ body: screen });
|
||||||
|
});
|
||||||
|
const discarded = expect(mutation).rejects.toMatchObject({
|
||||||
|
name: "AbortError",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(started).toBe(2));
|
||||||
|
await act(async () => {
|
||||||
|
changed = true;
|
||||||
|
if (boundary === "connection")
|
||||||
|
commitTbankConnectionBoundary(queryClient, {
|
||||||
|
connected: false,
|
||||||
|
connectedAt: null,
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
generation += 1;
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
|
release();
|
||||||
|
await blocked;
|
||||||
|
});
|
||||||
|
await discarded;
|
||||||
|
expect(result.current.mutation.data).toBeUndefined();
|
||||||
|
const key = apiQueryClient.queryOptions(
|
||||||
|
"get",
|
||||||
|
"/api/v1/tbank-sync/{id}",
|
||||||
|
{ params: { path: { id: "work" } } },
|
||||||
|
).queryKey;
|
||||||
|
// A new request may start after a successful connection boundary; an old result cannot win it.
|
||||||
|
if (boundary === "session")
|
||||||
|
expect(queryClient.getQueryData(key)).toBeUndefined();
|
||||||
|
else
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(queryClient.getQueryData(key)).toMatchObject({
|
||||||
|
now: "2026-09-25T12:01:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(published).not.toContain(accepted.now);
|
||||||
|
unsubscribe();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
|
type InvestmentScreen =
|
||||||
|
paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
|
||||||
|
function useTbankSync() {
|
||||||
|
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function useTbankSyncStatus(id: string | null) {
|
||||||
|
return useAuthenticatedQuery({
|
||||||
|
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
|
||||||
|
params: { path: { id: id ?? "" } },
|
||||||
|
}),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
renderHook,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -12,9 +18,11 @@ import {
|
|||||||
} from "react-router";
|
} from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createQueryWrapper, createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
|
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||||
|
|
||||||
const Navigation: FC = () => {
|
const Navigation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
|
|||||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||||
({ request }) => {
|
({ request }) => {
|
||||||
searches.push(new URL(request.url).search);
|
searches.push(new URL(request.url).search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId,
|
accountId,
|
||||||
calculatedAt: "2099-01-01T00:00:00Z",
|
calculatedAt: "2099-01-01T00:00:00Z",
|
||||||
events: [],
|
events: [],
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotalsByCurrency: [],
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "synthetic",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
date: "2026-10-01",
|
||||||
|
displayName: "Synthetic coupon",
|
||||||
|
type: "coupon",
|
||||||
|
projectedAmount: { currency: "RUB", value: "123.45" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const snapshot = resource(
|
||||||
|
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
||||||
|
{ sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
|
||||||
|
);
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json(snapshot),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||||
|
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<QueryWrapper>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryWrapper>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
state: "loaded",
|
||||||
|
events,
|
||||||
|
sourceError: true,
|
||||||
|
refreshError: true,
|
||||||
|
lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
|
|
||||||
|
const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
|
||||||
|
vi.mock("@/features/tbank-connection", () => ({
|
||||||
|
useScreenSync: () => ({
|
||||||
|
refresh,
|
||||||
|
refreshing: false,
|
||||||
|
sourceError: false,
|
||||||
|
transportError: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
||||||
useBrokerageEvents: vi.fn(),
|
useBrokerageEvents: vi.fn(),
|
||||||
@@ -24,12 +33,17 @@ function renderPage() {
|
|||||||
|
|
||||||
describe("AccountEventsPage", () => {
|
describe("AccountEventsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
refresh.mockClear();
|
||||||
query.mockReturnValue({
|
query.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
accountId: "account",
|
accountId: "account",
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
events: [],
|
events: [],
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
availability: "complete",
|
||||||
|
sync: { state: "idle", id: null, errorCode: null },
|
||||||
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
|
|||||||
it("keeps the loading fallback when no result or account is available", () => {
|
it("keeps the loading fallback when no result or account is available", () => {
|
||||||
query.mockReturnValue({
|
query.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
meta: {
|
||||||
|
availability: "complete",
|
||||||
|
sync: { state: "idle", id: null, errorCode: null },
|
||||||
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
availability: "complete",
|
||||||
|
sync: { state: "idle", id: null, errorCode: null },
|
||||||
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
|
|||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
screen.getByRole("button", { name: "Повторить" }).click();
|
screen.getByRole("button", { name: "Повторить" }).click();
|
||||||
expect(refetch).toHaveBeenCalledOnce();
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a structural busy state before events are available", () => {
|
it("shows a structural busy state before events are available", () => {
|
||||||
query.mockReturnValue({
|
query.mockReturnValue({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
meta: {
|
||||||
|
availability: "complete",
|
||||||
|
sync: { state: "idle", id: null, errorCode: null },
|
||||||
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type BrokerageEvent,
|
type BrokerageEvent,
|
||||||
useBrokerageEvents,
|
useBrokerageEvents,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
|
|
||||||
type AccountEventsPageViewModel =
|
type AccountEventsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
|
|||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
events: BrokerageEvent[];
|
events: BrokerageEvent[];
|
||||||
|
sourceError?: boolean;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
partial?: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
|
|||||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
...(appliedTo ? { to: appliedTo } : {}),
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
});
|
});
|
||||||
|
const sync = useScreenSync(
|
||||||
|
{
|
||||||
|
screen: "events",
|
||||||
|
accountId,
|
||||||
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
|
},
|
||||||
|
[events],
|
||||||
|
);
|
||||||
if (events.isLoading) return { state: "loading" };
|
if (events.isLoading) return { state: "loading" };
|
||||||
if (events.isError && !events.data)
|
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||||
return {
|
return {
|
||||||
state: "error",
|
state: "error",
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void events.refetch();
|
void sync.refresh();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (!events.data) return { state: "loading" };
|
if (!events.data) return { state: "loading" };
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
events: events.data.events,
|
events: events.data.events,
|
||||||
refreshing: events.isFetching,
|
sourceError: sync.sourceError,
|
||||||
refreshError: events.isError,
|
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||||
|
partial: events.meta?.availability !== "complete",
|
||||||
|
refreshing: events.isFetching || sync.refreshing,
|
||||||
|
refreshError: events.isError || sync.sourceError || sync.transportError,
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void events.refetch();
|
void sync.refresh();
|
||||||
},
|
},
|
||||||
draftFrom,
|
draftFrom,
|
||||||
draftTo,
|
draftTo,
|
||||||
|
|||||||
+22
-4
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||||
|
|
||||||
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{props.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
|
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||||
|
{t("investment.refresh")}
|
||||||
|
</Button>
|
||||||
|
{props.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : null}
|
||||||
{props.refreshing ? (
|
{props.refreshing ? (
|
||||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||||
) : null}
|
) : null}
|
||||||
{props.refreshError ? (
|
{props.refreshError ? (
|
||||||
<Alert color="yellow">
|
<Alert color="yellow" role="status" aria-live="polite">
|
||||||
{t("accounts.refreshError")}
|
{t(
|
||||||
|
props.sourceError
|
||||||
|
? "investment.syncFailed"
|
||||||
|
: "accounts.refreshError",
|
||||||
|
)}
|
||||||
<Button variant="subtle" onClick={props.retry}>
|
<Button variant="subtle" onClick={props.retry}>
|
||||||
{t("accounts.retry")}
|
{t("accounts.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : !props.partial ? (
|
||||||
<AccountEventsPageEmpty />
|
<AccountEventsPageEmpty />
|
||||||
)}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resource } from "@/test/resource";
|
||||||
import "dayjs/locale/ru";
|
import "dayjs/locale/ru";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { DatesProvider } from "@mantine/dates";
|
import { DatesProvider } from "@mantine/dates";
|
||||||
@@ -154,7 +155,7 @@ describe("AccountIncomePage", () => {
|
|||||||
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
|
||||||
({ request }) => {
|
({ request }) => {
|
||||||
requested.push(new URL(request.url).search);
|
requested.push(new URL(request.url).search);
|
||||||
return HttpResponse.json(analytics);
|
return HttpResponse.json(resource(analytics));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -236,7 +237,7 @@ describe("AccountIncomePage", () => {
|
|||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json(response),
|
HttpResponse.json(resource(response)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -284,7 +285,7 @@ describe("AccountIncomePage", () => {
|
|||||||
])("shows a local error for %s", async (_name, response) => {
|
])("shows a local error for %s", async (_name, response) => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json(response),
|
HttpResponse.json(resource(response)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -299,7 +300,8 @@ describe("AccountIncomePage", () => {
|
|||||||
it("accepts an empty analytics response", async () => {
|
it("accepts an empty analytics response", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
...analytics,
|
...analytics,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
buckets: [],
|
buckets: [],
|
||||||
@@ -307,6 +309,7 @@ describe("AccountIncomePage", () => {
|
|||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(
|
expect(
|
||||||
@@ -347,7 +350,7 @@ describe("AccountIncomePage", () => {
|
|||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json(response),
|
HttpResponse.json(resource(response)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -369,7 +372,8 @@ describe("AccountIncomePage", () => {
|
|||||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
...analytics,
|
...analytics,
|
||||||
yearComparison: {
|
yearComparison: {
|
||||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||||
@@ -377,6 +381,7 @@ describe("AccountIncomePage", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -392,13 +397,16 @@ describe("AccountIncomePage", () => {
|
|||||||
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
||||||
expect(eventsRequest).not.toHaveBeenCalled();
|
expect(eventsRequest).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||||
const selected = screen.getByRole("region", {
|
const year = screen.getByRole("region", { name: "Годом ранее" });
|
||||||
|
const selected = within(year).getByRole("region", {
|
||||||
name: "01.08.2026 – 27.08.2026",
|
name: "01.08.2026 – 27.08.2026",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
|
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Предыдущий период" }),
|
||||||
|
).toBeVisible();
|
||||||
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
|
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
|
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -409,7 +417,7 @@ describe("AccountIncomePage", () => {
|
|||||||
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
|
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
|
||||||
expect(within(selected).getByText(amount)).toBeInTheDocument();
|
expect(within(selected).getByText(amount)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
const difference = screen.getByRole("region", { name: "Разница" });
|
const difference = within(year).getByRole("region", { name: "Разница" });
|
||||||
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
|
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
|
||||||
expect(within(difference).getByText(amount)).toBeInTheDocument();
|
expect(within(difference).getByText(amount)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
@@ -425,7 +433,18 @@ describe("AccountIncomePage", () => {
|
|||||||
it("shows the unavailable year state without falling back to the previous period", async () => {
|
it("shows the unavailable year state without falling back to the previous period", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
HttpResponse.json(analytics),
|
HttpResponse.json(
|
||||||
|
resource(analytics, {
|
||||||
|
dependencies: [
|
||||||
|
resource(analytics).meta,
|
||||||
|
resource(analytics).meta,
|
||||||
|
{
|
||||||
|
...resource(null).meta,
|
||||||
|
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -433,9 +452,11 @@ describe("AccountIncomePage", () => {
|
|||||||
await screen.findByText("Сравнение с годом ранее недоступно."),
|
await screen.findByText("Сравнение с годом ранее недоступно."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("region", { name: "Разница" }),
|
screen.queryByRole("region", { name: "Годом ранее" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("region", { name: "Предыдущий период" }),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a recoverable realized error", async () => {
|
it("shows a recoverable realized error", async () => {
|
||||||
@@ -537,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) {
|
|||||||
({ request }) => {
|
({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url.search);
|
requests.push(url.search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
...analytics,
|
...analytics,
|
||||||
period: {
|
period: {
|
||||||
from: url.searchParams.get("from"),
|
from: url.searchParams.get("from"),
|
||||||
to: url.searchParams.get("to"),
|
to: url.searchParams.get("to"),
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -669,7 +692,32 @@ describe("income period and snapshot boundary", () => {
|
|||||||
await screen.findByText(/Показан последний загруженный результат/),
|
await screen.findByText(/Показан последний загруженный результат/),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||||
respondWithRequestedPeriod([]);
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "income",
|
||||||
|
scope: { screen: "income", accountId: "account-1" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
id: "income",
|
||||||
|
scope: null,
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 1, completed: 1, discovering: false },
|
||||||
|
resources: [resource(null, { stateVersion: "2" }).meta],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
|
||||||
|
),
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -694,10 +742,12 @@ describe("income period and snapshot boundary", () => {
|
|||||||
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
"*/api/v1/brokerage-accounts/account-1/income-analytics",
|
||||||
async () => {
|
async () => {
|
||||||
await pending;
|
await pending;
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
...analytics,
|
...analytics,
|
||||||
period: { from: "2026-08-02", to: "2026-08-27" },
|
period: { from: "2026-08-02", to: "2026-08-27" },
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -778,3 +828,64 @@ describe("income period and snapshot boundary", () => {
|
|||||||
expect(screen.getAllByText("Купон")).toHaveLength(6);
|
expect(screen.getAllByText("Купон")).toHaveLength(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource({ ...analytics, previous: null, yearComparison: null }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("region", { name: "Год назад" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("Период сравнения ещё не загружен полностью."),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Сравнение с годом ранее недоступно."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("region", { name: "Разница" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Период")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(analytics, {
|
||||||
|
dependencies: [
|
||||||
|
resource(analytics).meta,
|
||||||
|
resource(analytics).meta,
|
||||||
|
{
|
||||||
|
...resource(null).meta,
|
||||||
|
sync: {
|
||||||
|
id: null,
|
||||||
|
state: "failed",
|
||||||
|
errorCode: "TBANK_UNAVAILABLE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("region", { name: "Предыдущий период" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("region", { name: "Годом ранее" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
import { formatDate } from "@/shared/lib";
|
import { formatDate } from "@/shared/lib";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,16 @@ import {
|
|||||||
|
|
||||||
const ISO_MONTH_LENGTH = 7;
|
const ISO_MONTH_LENGTH = 7;
|
||||||
|
|
||||||
|
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
|
||||||
|
type ComparisonNotice =
|
||||||
|
| "comparisonPending"
|
||||||
|
| "comparisonFailed"
|
||||||
|
| "yearUnavailable";
|
||||||
|
|
||||||
type IncomeDisplayModel = {
|
type IncomeDisplayModel = {
|
||||||
|
previousComparison: IncomeDisplayModel["comparison"];
|
||||||
|
previousNotice: ComparisonNotice;
|
||||||
|
yearNotice: ComparisonNotice;
|
||||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||||||
comparison: {
|
comparison: {
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
@@ -38,6 +48,9 @@ type AccountIncomePageViewModel =
|
|||||||
analytics: IncomeAnalytics;
|
analytics: IncomeAnalytics;
|
||||||
display: IncomeDisplayModel;
|
display: IncomeDisplayModel;
|
||||||
formRevision: number;
|
formRevision: number;
|
||||||
|
sourceError?: boolean;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
partial?: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
@@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = (
|
|||||||
});
|
});
|
||||||
const [formRevision, setFormRevision] = useState(0);
|
const [formRevision, setFormRevision] = useState(0);
|
||||||
const analytics = useIncomeAnalytics(accountId, period);
|
const analytics = useIncomeAnalytics(accountId, period);
|
||||||
|
const sync = useScreenSync(
|
||||||
|
{ screen: "income", accountId, from: period.from, to: period.to },
|
||||||
|
[analytics],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
||||||
@@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = (
|
|||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
};
|
};
|
||||||
if (analytics.isLoading || !analytics.data) {
|
if (analytics.isLoading || !analytics.data) {
|
||||||
if (analytics.isError)
|
if (analytics.isError || analytics.meta?.sync.state === "failed")
|
||||||
return { state: "error", retry: () => analytics.refetch(), period };
|
return { state: "error", retry: sync.refresh, period };
|
||||||
return { state: "loading" };
|
return { state: "loading" };
|
||||||
}
|
}
|
||||||
const display = toIncomeDisplayModel(analytics.data);
|
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
||||||
if (!display)
|
if (!display)
|
||||||
return { state: "error", retry: () => analytics.refetch(), period };
|
return {
|
||||||
|
state: "error",
|
||||||
|
retry: () => {
|
||||||
|
void analytics.observe();
|
||||||
|
},
|
||||||
|
period,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
analytics: analytics.data,
|
analytics: analytics.data,
|
||||||
display,
|
display,
|
||||||
formRevision,
|
formRevision,
|
||||||
refreshing: analytics.isFetching,
|
sourceError: sync.sourceError,
|
||||||
refreshError: analytics.isError,
|
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||||
retry: () => analytics.refetch(),
|
partial: analytics.meta?.availability !== "complete",
|
||||||
|
refreshing: analytics.isFetching || sync.refreshing,
|
||||||
|
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
||||||
|
retry: sync.refresh,
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onReset,
|
onReset,
|
||||||
@@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
|||||||
|
|
||||||
function toIncomeDisplayModel(
|
function toIncomeDisplayModel(
|
||||||
analytics: IncomeAnalytics,
|
analytics: IncomeAnalytics,
|
||||||
|
meta?: IncomeMeta,
|
||||||
): IncomeDisplayModel | null {
|
): IncomeDisplayModel | null {
|
||||||
const instruments = new Map<
|
const instruments = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -209,15 +236,37 @@ function toIncomeDisplayModel(
|
|||||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
if (instrument.includedCount !== instrument.receiptCount) return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The income endpoint returns selected, previous and year dependencies in order.
|
||||||
|
const [, previous, year] = meta?.dependencies ?? [];
|
||||||
return {
|
return {
|
||||||
entries,
|
entries,
|
||||||
comparison: analytics.yearComparison
|
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
|
||||||
? {
|
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||||
selectedPeriod: formatComparisonPeriod(analytics.period),
|
previousNotice: comparisonNotice(previous),
|
||||||
comparedPeriod: formatComparisonPeriod(
|
yearNotice: comparisonNotice(year),
|
||||||
analytics.yearComparison.period,
|
};
|
||||||
),
|
}
|
||||||
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
|
|
||||||
|
function comparisonNotice(
|
||||||
|
dependency:
|
||||||
|
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
|
||||||
|
| undefined,
|
||||||
|
): ComparisonNotice {
|
||||||
|
if (dependency?.unavailableReason) return "yearUnavailable";
|
||||||
|
return dependency?.sync.state === "failed"
|
||||||
|
? "comparisonFailed"
|
||||||
|
: "comparisonPending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparisonDisplay(
|
||||||
|
period: IncomePeriod,
|
||||||
|
comparison: IncomeAnalytics["yearComparison"],
|
||||||
|
): IncomeDisplayModel["comparison"] {
|
||||||
|
if (!comparison) return null;
|
||||||
|
return {
|
||||||
|
selectedPeriod: formatComparisonPeriod(period),
|
||||||
|
comparedPeriod: formatComparisonPeriod(comparison.period),
|
||||||
|
values: comparison.totalsByCurrency.map((value) => ({
|
||||||
...value,
|
...value,
|
||||||
percentages: {
|
percentages: {
|
||||||
total: formatIncomeChange(
|
total: formatIncomeChange(
|
||||||
@@ -234,8 +283,6 @@ function toIncomeDisplayModel(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-4
@@ -11,6 +11,7 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||||
@@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg" className={classes.page}>
|
<Stack gap="lg" className={classes.page}>
|
||||||
|
{props.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Title order={1}>{t("income.title")}</Title>
|
<Title order={1}>{t("income.title")}</Title>
|
||||||
|
<Button onClick={retry} disabled={refreshing} w="fit-content">
|
||||||
|
{t("investment.refresh")}
|
||||||
|
</Button>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<IncomePeriodForm
|
<IncomePeriodForm
|
||||||
key={props.formRevision}
|
key={props.formRevision}
|
||||||
@@ -49,16 +60,24 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
{refreshError ? (
|
{refreshError ? (
|
||||||
<Alert color="orange">
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text>{t("income.refreshError")}</Text>
|
<Text>
|
||||||
|
{t(
|
||||||
|
props.sourceError
|
||||||
|
? "investment.syncFailed"
|
||||||
|
: "income.refreshError",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
<Button onClick={retry} variant="default">
|
<Button onClick={retry} variant="default">
|
||||||
{t("income.retry")}
|
{t("income.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{analytics.totalsByCurrency.length ? (
|
{props.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : analytics.totalsByCurrency.length ? (
|
||||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||||
) : (
|
) : (
|
||||||
<StateView
|
<StateView
|
||||||
@@ -67,7 +86,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||||
<IncomeComparisons comparison={display.comparison} />
|
<IncomeComparisons
|
||||||
|
title={t("income.previousPeriod")}
|
||||||
|
comparison={display.previousComparison}
|
||||||
|
notice={t(`income.${display.previousNotice}`)}
|
||||||
|
/>
|
||||||
|
<IncomeComparisons
|
||||||
|
title={t("income.yearAgo")}
|
||||||
|
comparison={display.comparison}
|
||||||
|
notice={t(`income.${display.yearNotice}`)}
|
||||||
|
/>
|
||||||
<IncomePayments entries={display.entries} />
|
<IncomePayments entries={display.entries} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
-8
@@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib";
|
|||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
|
type IncomeComparisonsProps = {
|
||||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
|
comparison: IncomeDisplayModel["comparison"];
|
||||||
|
title: string;
|
||||||
|
notice: string;
|
||||||
|
};
|
||||||
|
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
type ComparisonValuesProps = {
|
type ComparisonValuesProps = {
|
||||||
kind: "selected" | "compared" | "difference";
|
kind: "selected" | "compared" | "difference";
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,16 +21,15 @@ type ComparisonValuesProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{t("income.yearAgo")}</Title>
|
<Title order={2}>{props.title}</Title>
|
||||||
{props.comparison ? (
|
{props.comparison ? (
|
||||||
<Comparison {...props.comparison} />
|
<Comparison {...props.comparison} title={props.title} />
|
||||||
) : (
|
) : (
|
||||||
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||||
<Text size="sm" c="gray.7">
|
<Text size="sm" c="gray.7" role="status" aria-live="polite">
|
||||||
{t("income.yearUnavailable")}
|
{props.notice}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
@@ -35,7 +40,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
|||||||
const Comparison: FC<ComparisonProps> = (props) => {
|
const Comparison: FC<ComparisonProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
|
<section aria-label={props.title} className={classes.comparison}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{props.values.map((value) => (
|
{props.values.map((value) => (
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
+72
-8
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountOperationsPage } from "./AccountOperationsPage";
|
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||||||
|
|
||||||
@@ -39,11 +40,13 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(search = "") {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
<MemoryRouter
|
||||||
|
initialEntries={[`/accounts/account-1/operations${search}`]}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/accounts/:accountId/operations"
|
path="/accounts/:accountId/operations"
|
||||||
@@ -193,13 +196,15 @@ describe("AccountOperationsPage", () => {
|
|||||||
({ request }) => {
|
({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
searches.push(url.search);
|
searches.push(url.search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
items: [operation],
|
items: [operation],
|
||||||
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
nextCursor: url.searchParams.get("cursor") ? null : "next-1",
|
||||||
hasNext: !url.searchParams.get("cursor"),
|
hasNext: !url.searchParams.get("cursor"),
|
||||||
observedAt: "2026-08-14T00:00:00Z",
|
observedAt: "2026-08-14T00:00:00Z",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
|
|||||||
({ request }) => {
|
({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
searches.push(url.search);
|
searches.push(url.search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
items: [operation],
|
items: [operation],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
observedAt: "2026-08-14T00:00:00Z",
|
observedAt: "2026-08-14T00:00:00Z",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
|
|||||||
({ request }) => {
|
({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
searches.push(url.search);
|
searches.push(url.search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
items: [operation],
|
items: [operation],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
observedAt: "2026-08-14T00:00:00Z",
|
observedAt: "2026-08-14T00:00:00Z",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not label incomplete empty operations as a confirmed empty period", async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
|
{
|
||||||
|
accountId: "account-1",
|
||||||
|
items: [],
|
||||||
|
hasNext: false,
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
{ availability: "partial" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
|
||||||
|
expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
const searches: URLSearchParams[] = [];
|
||||||
|
server.use(
|
||||||
|
http.get(
|
||||||
|
"*/api/v1/brokerage-accounts/:accountId/operations",
|
||||||
|
({ request }) => {
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
searches.push(params);
|
||||||
|
if (params.has("cursor"))
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "INVALID_OPERATIONS_CURSOR" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
|
items: [operation],
|
||||||
|
hasNext: true,
|
||||||
|
nextCursor: "old-cursor",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||||
|
await waitFor(() => expect(searches.length).toBe(3));
|
||||||
|
expect(searches[2].get("cursor")).toBeNull();
|
||||||
|
for (const key of ["from", "to", "categories"])
|
||||||
|
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
|
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
type AccountOperationsPageViewModel =
|
type AccountOperationsPageViewModel =
|
||||||
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
|
|||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
|
sourceError?: boolean;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
partial?: boolean;
|
||||||
|
historyLoading?: boolean;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
|
|||||||
...firstPageQuery,
|
...firstPageQuery,
|
||||||
...(cursor ? { cursor } : {}),
|
...(cursor ? { cursor } : {}),
|
||||||
});
|
});
|
||||||
|
const sync = useScreenSync(
|
||||||
|
{ screen: "operations", accountId, ...firstPageQuery },
|
||||||
|
[operations],
|
||||||
|
);
|
||||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}).queryKey;
|
}).queryKey;
|
||||||
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
|
|||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
resetCursors();
|
resetCursors();
|
||||||
} else void operations.refetch();
|
} else void sync.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (operations.isLoading) return { state: "loading" };
|
if (operations.isLoading) return { state: "loading" };
|
||||||
if (
|
if (
|
||||||
operations.isError &&
|
(operations.isError || operations.meta?.sync.state === "failed") &&
|
||||||
(!operations.data ||
|
(!operations.data ||
|
||||||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
||||||
)
|
)
|
||||||
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
refreshing: operations.isFetching,
|
sourceError: sync.sourceError,
|
||||||
refreshError: operations.isError,
|
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||||
|
partial: operations.meta?.availability !== "complete",
|
||||||
|
historyLoading:
|
||||||
|
operations.meta?.coverage?.historyState === "loading" ||
|
||||||
|
operations.meta?.coverage?.historyState === "partial",
|
||||||
|
refreshing: operations.isFetching || sync.refreshing,
|
||||||
|
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
||||||
retry,
|
retry,
|
||||||
draftFrom,
|
draftFrom,
|
||||||
draftTo,
|
draftTo,
|
||||||
|
|||||||
+24
-4
@@ -5,6 +5,7 @@ import {
|
|||||||
MultiSelect,
|
MultiSelect,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||||
|
|
||||||
type AccountOperationsPageLoadedProps = Extract<
|
type AccountOperationsPageLoadedProps = Extract<
|
||||||
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{props.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Title order={1}>{t("ledger.title")}</Title>
|
<Title order={1}>{t("ledger.title")}</Title>
|
||||||
|
<Button onClick={props.retry} disabled={props.refreshing} w="fit-content">
|
||||||
|
{t("investment.refresh")}
|
||||||
|
</Button>
|
||||||
|
{props.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : null}
|
||||||
|
{props.historyLoading ? <Alert>{t("investment.history")}</Alert> : null}
|
||||||
{props.refreshing ? (
|
{props.refreshing ? (
|
||||||
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
|
||||||
) : null}
|
) : null}
|
||||||
{props.refreshError ? (
|
{props.refreshError ? (
|
||||||
<Alert color="yellow">
|
<Alert color="yellow" role="status" aria-live="polite">
|
||||||
{t("accounts.refreshError")}
|
{t(
|
||||||
|
props.sourceError
|
||||||
|
? "investment.syncFailed"
|
||||||
|
: "accounts.refreshError",
|
||||||
|
)}
|
||||||
<Button variant="subtle" onClick={props.retry}>
|
<Button variant="subtle" onClick={props.retry}>
|
||||||
{t("accounts.retry")}
|
{t("accounts.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
operation={operation}
|
operation={operation}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : !props.partial ? (
|
||||||
<StateView
|
<StateView
|
||||||
title={t("ledger.emptyTitle")}
|
title={t("ledger.emptyTitle")}
|
||||||
text={t("ledger.emptyText")}
|
text={t("ledger.emptyText")}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
{hasNext || hasPrevious ? (
|
{hasNext || hasPrevious ? (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Button disabled={!hasPrevious} onClick={onPrevious}>
|
<Button disabled={!hasPrevious} onClick={onPrevious}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
|
|||||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountPage } from "./AccountPage";
|
import { AccountPage } from "./AccountPage";
|
||||||
import type {
|
import type {
|
||||||
@@ -144,7 +145,8 @@ describe("AccountPage states", () => {
|
|||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions:
|
positions:
|
||||||
accountId === "all"
|
accountId === "all"
|
||||||
@@ -155,16 +157,19 @@ describe("AccountPage states", () => {
|
|||||||
: [position],
|
: [position],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get(`*/api/v1/${collection}/:uid`, () => {
|
http.get(`*/api/v1/${collection}/:uid`, () => {
|
||||||
requests();
|
requests();
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
instrumentUid: uid,
|
instrumentUid: uid,
|
||||||
slug,
|
slug,
|
||||||
name: "Название из карточки инструмента",
|
name: "Название из карточки инструмента",
|
||||||
ticker: "OTHER-TICKER",
|
ticker: "OTHER-TICKER",
|
||||||
isin: "OTHER-ISIN",
|
isin: "OTHER-ISIN",
|
||||||
logoName: "other.png",
|
logoName: "other.png",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
@@ -204,7 +209,8 @@ describe("AccountPage states", () => {
|
|||||||
const requests = vi.fn();
|
const requests = vi.fn();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
@@ -215,11 +221,12 @@ describe("AccountPage states", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/stocks/:uid", () => {
|
http.get("*/api/v1/stocks/:uid", () => {
|
||||||
requests();
|
requests();
|
||||||
return scenario === "unavailable"
|
return scenario === "unavailable"
|
||||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
: HttpResponse.json({ instrumentUid: uid, slug: uid });
|
: HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
renderAccountRoute(createTestQueryClient());
|
renderAccountRoute(createTestQueryClient());
|
||||||
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
|
|||||||
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json({ ...portfolio, structure: undefined }),
|
HttpResponse.json(resource({ ...portfolio, structure: undefined })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderAccountRoute(createTestQueryClient());
|
renderAccountRoute(createTestQueryClient());
|
||||||
@@ -322,12 +329,14 @@ describe("AccountPage states", () => {
|
|||||||
async (hasPositions) => {
|
async (hasPositions) => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
positions: hasPositions ? portfolio.positions : [],
|
positions: hasPositions ? portfolio.positions : [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
renderAccountRoute(createTestQueryClient());
|
renderAccountRoute(createTestQueryClient());
|
||||||
await screen.findByRole("heading", { name: "Портфель", level: 1 });
|
await screen.findByRole("heading", { name: "Портфель", level: 1 });
|
||||||
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
|
|||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(snapshot),
|
HttpResponse.json(resource(snapshot)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
|
|||||||
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
|
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
|
||||||
expect(
|
expect(
|
||||||
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
|
||||||
).toEqual(snapshot);
|
).toEqual(resource(snapshot));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
|
|||||||
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
expect(screen.getByText("Вторая")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([503, 404])(
|
it.each([503])(
|
||||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const initialPortfolio: Portfolio = {
|
const initialPortfolio: Portfolio = {
|
||||||
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
|
|||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
let resolveRetry: ((response: Response) => void) | undefined;
|
let resolveRetry: ((response: Response) => void) | undefined;
|
||||||
server.use(
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "overview",
|
||||||
|
scope: { screen: "account", accountId: "account-1" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
id: "overview",
|
||||||
|
scope: { screen: "account", accountId: "account-1" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 1, completed: 1, discovering: false },
|
||||||
|
resources: [
|
||||||
|
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
|
||||||
|
.meta,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
if (requestCount === 1)
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(initialPortfolio, { resourceKey: "portfolio" }),
|
||||||
|
);
|
||||||
if (requestCount === 3)
|
if (requestCount === 3)
|
||||||
return new Promise<Response>((resolve) => {
|
return new Promise<Response>((resolve) => {
|
||||||
resolveRetry = resolve;
|
resolveRetry = resolve;
|
||||||
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
|
|||||||
snapshotText,
|
snapshotText,
|
||||||
);
|
);
|
||||||
resolveRetry?.(
|
resolveRetry?.(
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
|
{
|
||||||
...portfolio,
|
...portfolio,
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||||
}),
|
},
|
||||||
|
{ resourceKey: "portfolio", stateVersion: "2" },
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
if (requestCount === 1) return HttpResponse.json(resource(portfolio));
|
||||||
return new Promise<Response>((resolve) => {
|
return new Promise<Response>((resolve) => {
|
||||||
resolveRefresh = resolve;
|
resolveRefresh = resolve;
|
||||||
});
|
});
|
||||||
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
|
|||||||
screen.getByRole("status", { name: "Обновляем портфель" }),
|
screen.getByRole("status", { name: "Обновляем портфель" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
resolveRefresh?.(HttpResponse.json(portfolio));
|
resolveRefresh?.(HttpResponse.json(resource(portfolio)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
|||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={viewModel.accountId}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
|
sourceError={viewModel.sourceError}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={viewModel.recentOperations}
|
||||||
@@ -28,9 +29,11 @@ const AccountPage: FC<AccountPageProps> = (props) => {
|
|||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={viewModel.accountId}
|
||||||
|
partial={viewModel.partial}
|
||||||
portfolio={viewModel.portfolio}
|
portfolio={viewModel.portfolio}
|
||||||
positionSections={viewModel.positionSections}
|
positionSections={viewModel.positionSections}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
|
sourceError={viewModel.sourceError}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={viewModel.recentOperations}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import {
|
import {
|
||||||
toAccountPagePositionSections,
|
toAccountPagePositionSections,
|
||||||
@@ -86,10 +87,11 @@ function createWrapper() {
|
|||||||
function mockLoadedAccountPage() {
|
function mockLoadedAccountPage() {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(portfolio),
|
HttpResponse.json(resource(portfolio)),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@@ -114,8 +116,10 @@ function mockLoadedAccountPage() {
|
|||||||
observedAt: "2026-08-13T10:00:00Z",
|
observedAt: "2026-08-13T10:00:00Z",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
calculatedAt: "2026-08-13T10:00:00Z",
|
calculatedAt: "2026-08-13T10:00:00Z",
|
||||||
events: Array.from({ length: 6 }, (_, index) => ({
|
events: Array.from({ length: 6 }, (_, index) => ({
|
||||||
@@ -130,6 +134,7 @@ function mockLoadedAccountPage() {
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json(portfolio),
|
HttpResponse.json(resource(portfolio)),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps overview sections independent when their source data is incomplete", async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
mockLoadedAccountPage();
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(resource(portfolio, { availability: "partial" })),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
|
{ items: [], hasNext: false, nextCursor: null },
|
||||||
|
{ availability: "partial" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(
|
||||||
|
{ events: [], incomeTotalsByCurrency: [] },
|
||||||
|
{ availability: "partial" },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
partial: true,
|
||||||
|
recentOperations: { state: "partial", items: [] },
|
||||||
|
recentEvents: { state: "partial", items: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes every overview section through one account descriptor and observational reads", async () => {
|
||||||
|
configureTestAuth();
|
||||||
|
const sections: Record<string, unknown> = {
|
||||||
|
portfolio,
|
||||||
|
operations: { items: [], hasNext: false, nextCursor: null },
|
||||||
|
events: { events: [], incomeTotalsByCurrency: [] },
|
||||||
|
"income-summary": { totalsByCurrency: [] },
|
||||||
|
};
|
||||||
|
const observed: string[] = [];
|
||||||
|
const scopes: unknown[] = [];
|
||||||
|
let version = "1";
|
||||||
|
server.use(
|
||||||
|
...Object.entries(sections).map(([path, data]) =>
|
||||||
|
http.get(
|
||||||
|
`*/api/v1/brokerage-accounts/:accountId/${path}`,
|
||||||
|
({ request }) => {
|
||||||
|
if (new URL(request.url).searchParams.get("sync") === "observe")
|
||||||
|
observed.push(path);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource(data, { resourceKey: path, stateVersion: version }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
scopes.push(await request.json());
|
||||||
|
version = "2";
|
||||||
|
return HttpResponse.json(
|
||||||
|
{
|
||||||
|
id: "overview",
|
||||||
|
scope: { screen: "account", accountId: "account-1" },
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
id: "overview",
|
||||||
|
scope: null,
|
||||||
|
now: "2026-09-25T00:00:00Z",
|
||||||
|
state: "succeeded",
|
||||||
|
errorCode: null,
|
||||||
|
progress: { total: 4, completed: 4, discovering: false },
|
||||||
|
resources: Object.keys(sections).map(
|
||||||
|
(resourceKey) =>
|
||||||
|
resource(null, { resourceKey, stateVersion: version }).meta,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
state: "loaded",
|
||||||
|
recentOperations: { state: "empty" },
|
||||||
|
recentEvents: { state: "empty" },
|
||||||
|
incomeSummary: { state: "empty" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
if (result.current.state === "loaded") await result.current.retry();
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(new Set(observed)).toEqual(new Set(Object.keys(sections))),
|
||||||
|
);
|
||||||
|
expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||||
import {
|
import {
|
||||||
type BrokerageAccount,
|
type BrokerageAccount,
|
||||||
brokerageAccountsQueryKey,
|
useBrokerageAccounts,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
import {
|
import {
|
||||||
type BrokerageEventsPreviewState,
|
type BrokerageEventsPreviewState,
|
||||||
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
|||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||||
import { useStocks } from "@/entities/stock";
|
import { useStocks } from "@/entities/stock";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
|
|||||||
to: string;
|
to: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded" | "partial";
|
||||||
to: string;
|
to: string;
|
||||||
items: BrokerageOperation[];
|
items: BrokerageOperation[];
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
stale?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
||||||
|
|
||||||
type AccountPageIncomeSummary =
|
type AccountPageIncomeSummary =
|
||||||
| { state: "loading" | "empty"; to: string }
|
| { state: "loading" | "partial" | "empty"; to: string }
|
||||||
| { state: "error"; to: string; retry: () => void }
|
| { state: "error"; to: string; retry: () => void }
|
||||||
| { state: "loaded"; to: string; summary: IncomeSummary };
|
| {
|
||||||
|
state: "loaded";
|
||||||
|
to: string;
|
||||||
|
summary: IncomeSummary;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
stale?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type AccountPageContent = {
|
type AccountPageContent = {
|
||||||
|
sourceError?: boolean;
|
||||||
|
partial?: boolean;
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
|||||||
});
|
});
|
||||||
const events = useBrokerageEvents(accountId);
|
const events = useBrokerageEvents(accountId);
|
||||||
const income = useIncomeSummary(accountId);
|
const income = useIncomeSummary(accountId);
|
||||||
const account = useQueryClient()
|
const accounts = useBrokerageAccounts();
|
||||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
const sync = useScreenSync({ screen: "account", accountId }, [
|
||||||
?.find((item) => item.id === accountId);
|
accounts,
|
||||||
|
portfolio,
|
||||||
|
operations,
|
||||||
|
events,
|
||||||
|
income,
|
||||||
|
]);
|
||||||
|
const account = accounts.data?.find((item) => item.id === accountId);
|
||||||
const recentOperations = toRecentOperations(accountId, operations);
|
const recentOperations = toRecentOperations(accountId, operations);
|
||||||
const recentEvents = toRecentEvents(accountId, events);
|
const recentEvents = toRecentEvents(accountId, events);
|
||||||
const incomeSummary = toIncomeSummary(accountId, income);
|
const incomeSummary = toIncomeSummary(accountId, income);
|
||||||
if (!portfolio.data) {
|
if (!portfolio.data) {
|
||||||
if (portfolio.isError)
|
if (portfolio.isError || portfolio.meta?.sync.state === "failed")
|
||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: apiErrorCode(portfolio.error),
|
code: apiErrorCode(portfolio.error),
|
||||||
retry: () => portfolio.refetch(),
|
retry: sync.refresh,
|
||||||
};
|
};
|
||||||
return { state: "loading" as const };
|
return { state: "loading" as const };
|
||||||
}
|
}
|
||||||
const refresh = {
|
const refresh = {
|
||||||
refreshing: portfolio.isFetching,
|
partial: portfolio.meta?.availability === "partial",
|
||||||
refreshError: portfolio.isError,
|
refreshing: portfolio.isFetching || sync.refreshing,
|
||||||
retry: () => portfolio.refetch(),
|
sourceError: sync.sourceError,
|
||||||
|
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
|
||||||
|
retry: sync.refresh,
|
||||||
};
|
};
|
||||||
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
|
if (
|
||||||
|
portfolio.meta?.availability === "complete" &&
|
||||||
|
!portfolio.data.positions.length &&
|
||||||
|
!portfolio.data.cashBalances.length
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
account,
|
account,
|
||||||
@@ -153,11 +177,20 @@ function toIncomeSummary(
|
|||||||
income: ReturnType<typeof useIncomeSummary>,
|
income: ReturnType<typeof useIncomeSummary>,
|
||||||
): AccountPageIncomeSummary {
|
): AccountPageIncomeSummary {
|
||||||
const to = `/accounts/${accountId}/income`;
|
const to = `/accounts/${accountId}/income`;
|
||||||
if (income.isLoading) return { state: "loading", to };
|
if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
|
||||||
if (income.isError)
|
return { state: "loading", to };
|
||||||
|
if (income.meta?.availability === "partial") return { state: "partial", to };
|
||||||
|
if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
|
||||||
return { state: "error", to, retry: () => income.refetch() };
|
return { state: "error", to, retry: () => income.refetch() };
|
||||||
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
|
||||||
return { state: "loaded", to, summary: income.data };
|
return {
|
||||||
|
state: "loaded",
|
||||||
|
to,
|
||||||
|
summary: income.data,
|
||||||
|
lastSuccessfulAt: income.meta?.lastSuccessfulAt,
|
||||||
|
stale: income.meta?.freshness === "stale",
|
||||||
|
refreshError: income.isError || income.meta?.sync.state === "failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRecentOperations(
|
function toRecentOperations(
|
||||||
@@ -165,10 +198,29 @@ function toRecentOperations(
|
|||||||
operations: ReturnType<typeof useBrokerageOperations>,
|
operations: ReturnType<typeof useBrokerageOperations>,
|
||||||
): AccountPageRecentOperations {
|
): AccountPageRecentOperations {
|
||||||
const to = `/accounts/${accountId}/operations`;
|
const to = `/accounts/${accountId}/operations`;
|
||||||
if (operations.isLoading) return { state: "loading", to };
|
if (
|
||||||
if (operations.isError) return { state: "error", to };
|
!operations.data &&
|
||||||
|
!operations.isError &&
|
||||||
|
operations.meta?.sync.state !== "failed"
|
||||||
|
)
|
||||||
|
return { state: "loading", to };
|
||||||
|
if (operations.meta?.availability === "partial")
|
||||||
|
return { state: "partial", to, items: operations.data?.items ?? [] };
|
||||||
|
if (
|
||||||
|
!operations.data &&
|
||||||
|
(operations.isError || operations.meta?.sync.state === "failed")
|
||||||
|
)
|
||||||
|
return { state: "error", to };
|
||||||
if (!operations.data?.items.length) return { state: "empty", to };
|
if (!operations.data?.items.length) return { state: "empty", to };
|
||||||
return { state: "loaded", to, items: operations.data.items };
|
return {
|
||||||
|
state: "loaded",
|
||||||
|
to,
|
||||||
|
items: operations.data.items,
|
||||||
|
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
||||||
|
stale: operations.meta?.freshness === "stale",
|
||||||
|
refreshError:
|
||||||
|
operations.isError || operations.meta?.sync.state === "failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRecentEvents(
|
function toRecentEvents(
|
||||||
@@ -176,11 +228,26 @@ function toRecentEvents(
|
|||||||
events: ReturnType<typeof useBrokerageEvents>,
|
events: ReturnType<typeof useBrokerageEvents>,
|
||||||
): AccountPageRecentEvents {
|
): AccountPageRecentEvents {
|
||||||
const to = `/accounts/${accountId}/events`;
|
const to = `/accounts/${accountId}/events`;
|
||||||
if (events.isLoading) return { state: "loading", to };
|
if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
|
||||||
if (events.isError) return { state: "error", to };
|
return { state: "loading", to };
|
||||||
|
if (events.meta?.availability === "partial")
|
||||||
|
return {
|
||||||
|
state: "partial",
|
||||||
|
to,
|
||||||
|
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
|
||||||
|
};
|
||||||
|
if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
|
||||||
|
return { state: "error", to };
|
||||||
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
|
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
|
||||||
if (!items.length) return { state: "empty", to };
|
if (!items.length) return { state: "empty", to };
|
||||||
return { state: "loaded", to, items };
|
return {
|
||||||
|
state: "loaded",
|
||||||
|
to,
|
||||||
|
items,
|
||||||
|
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
|
||||||
|
stale: events.meta?.freshness === "stale",
|
||||||
|
refreshError: events.isError || events.meta?.sync.state === "failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAccountPagePositionSections(
|
function toAccountPagePositionSections(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type AccountPageEmptyProps = {
|
|||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
|
sourceError?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
<PortfolioRefreshState
|
<PortfolioRefreshState
|
||||||
refreshing={props.refreshing ?? false}
|
refreshing={props.refreshing ?? false}
|
||||||
|
sourceError={props.sourceError}
|
||||||
refreshError={props.refreshError ?? false}
|
refreshError={props.refreshError ?? false}
|
||||||
retry={props.retry}
|
retry={props.retry}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ import { RecentOperations } from "./RecentOperations.view";
|
|||||||
type AccountPageLoadedProps = {
|
type AccountPageLoadedProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
partial?: boolean;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
|
sourceError?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
<PortfolioRefreshState
|
<PortfolioRefreshState
|
||||||
refreshing={props.refreshing ?? false}
|
refreshing={props.refreshing ?? false}
|
||||||
|
sourceError={props.sourceError}
|
||||||
refreshError={props.refreshError ?? false}
|
refreshError={props.refreshError ?? false}
|
||||||
retry={props.retry}
|
retry={props.retry}
|
||||||
/>
|
/>
|
||||||
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{props.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : (
|
||||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||||
|
)}
|
||||||
|
{!props.partial ? (
|
||||||
<PortfolioStructure
|
<PortfolioStructure
|
||||||
key={props.accountId}
|
key={props.accountId}
|
||||||
structure={props.portfolio.structure}
|
structure={props.portfolio.structure}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<Accordion
|
<Accordion
|
||||||
multiple
|
multiple
|
||||||
defaultValue={[
|
defaultValue={[
|
||||||
|
|||||||
@@ -4,18 +4,34 @@ import type { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
type IncomeSummaryProps =
|
type IncomeSummaryProps =
|
||||||
| { state: "loading"; to: string }
|
| { state: "loading"; to: string }
|
||||||
|
| { state: "partial"; to: string }
|
||||||
| { state: "error"; to: string; retry: () => void }
|
| { state: "error"; to: string; retry: () => void }
|
||||||
| { state: "empty"; to: string }
|
| { state: "empty"; to: string }
|
||||||
| { state: "loaded"; to: string; summary: IncomeSummaryData };
|
| {
|
||||||
|
state: "loaded";
|
||||||
|
to: string;
|
||||||
|
summary: IncomeSummaryData;
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
stale?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (props.state === "loading")
|
if (props.state === "loading" || props.state === "partial")
|
||||||
return <Text c="dimmed">{t("income.summaryLoading")}</Text>;
|
return (
|
||||||
|
<Text role="status" c="dimmed">
|
||||||
|
{t(
|
||||||
|
props.state === "partial"
|
||||||
|
? "investment.partial"
|
||||||
|
: "income.summaryLoading",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
if (props.state === "error")
|
if (props.state === "error")
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
@@ -38,6 +54,17 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
{props.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{props.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
|
||||||
|
{props.refreshError ? (
|
||||||
|
<Text role="status">{t("income.refreshError")}</Text>
|
||||||
|
) : null}
|
||||||
{props.summary.totalsByCurrency.map((total) => (
|
{props.summary.totalsByCurrency.map((total) => (
|
||||||
<Paper key={total.currency} p="sm" withBorder>
|
<Paper key={total.currency} p="sm" withBorder>
|
||||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||||
|
|||||||
+5
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
|
|||||||
name: "Ошибка обновления с retry",
|
name: "Ошибка обновления с retry",
|
||||||
args: { refreshError: true },
|
args: { refreshError: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SourceFailure: Story = {
|
||||||
|
name: "Ошибка синхронизации сохраняет суммы",
|
||||||
|
args: { refreshError: true, sourceError: true },
|
||||||
|
};
|
||||||
|
|||||||
+17
-6
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type PortfolioRefreshStateProps = {
|
type PortfolioRefreshStateProps = {
|
||||||
|
sourceError?: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
|
|||||||
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||||
const { refreshing, refreshError, retry } = props;
|
const { refreshing, refreshError, retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!refreshing && !refreshError) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={retry}
|
||||||
|
disabled={refreshing}
|
||||||
|
variant="light"
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||||
|
</Button>
|
||||||
{refreshing ? (
|
{refreshing ? (
|
||||||
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
|||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
{refreshError ? (
|
{refreshError ? (
|
||||||
<Alert color="orange">
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text>{t("portfolio.refreshError")}</Text>
|
<Text>
|
||||||
<Button onClick={retry} variant="light">
|
{t(
|
||||||
{t("accounts.retry")}
|
props.sourceError
|
||||||
</Button>
|
? "investment.syncFailed"
|
||||||
|
: "portfolio.refreshError",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||||
|
|
||||||
type RecentOperationsProps = {
|
type RecentOperationsProps = {
|
||||||
@@ -16,6 +17,23 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
|
{operations.state === "loaded" ? (
|
||||||
|
<>
|
||||||
|
{operations.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{operations.stale ? (
|
||||||
|
<Text size="xs">{t("investment.stale")}</Text>
|
||||||
|
) : null}
|
||||||
|
{operations.refreshError ? (
|
||||||
|
<Text role="status">{t("accounts.refreshError")}</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{operations.state === "loading" ? (
|
{operations.state === "loading" ? (
|
||||||
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
<LoadingSkeleton label={t("ledger.recentLoading")}>
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
@@ -29,7 +47,10 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
|||||||
{operations.state === "empty" ? (
|
{operations.state === "empty" ? (
|
||||||
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{operations.state === "loaded"
|
{operations.state === "partial" ? (
|
||||||
|
<Text role="status">{t("investment.partial")}</Text>
|
||||||
|
) : null}
|
||||||
|
{operations.state === "loaded" || operations.state === "partial"
|
||||||
? operations.items.map((operation) => (
|
? operations.items.map((operation) => (
|
||||||
<LedgerEntryRow
|
<LedgerEntryRow
|
||||||
key={JSON.stringify([
|
key={JSON.stringify([
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountsPage } from "./AccountsPage";
|
import { AccountsPage } from "./AccountsPage";
|
||||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||||
@@ -80,7 +81,7 @@ describe("AccountsPage", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json([account]);
|
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||||
return new Promise<Response>((resolve) => {
|
return new Promise<Response>((resolve) => {
|
||||||
resolveRefresh = resolve;
|
resolveRefresh = resolve;
|
||||||
});
|
});
|
||||||
@@ -100,21 +101,31 @@ describe("AccountsPage", () => {
|
|||||||
screen.getByRole("status", { name: "Обновляем данные" }),
|
screen.getByRole("status", { name: "Обновляем данные" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
resolveRefresh?.(HttpResponse.json([account]));
|
resolveRefresh?.(HttpResponse.json(resource([account])));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the cached account list and offers retry after a refresh error", async () => {
|
it("keeps the cached account list and offers retry after a refresh error", async () => {
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
requests.push(await request.json());
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json([account]);
|
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||||
if (requestCount === 2)
|
if (requestCount === 2)
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ code: "TBANK_UNAVAILABLE" },
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
return HttpResponse.json([account]);
|
return HttpResponse.json(resource([account]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
@@ -132,7 +143,8 @@ describe("AccountsPage", () => {
|
|||||||
expect(screen.getByText(account.name)).toBeInTheDocument();
|
expect(screen.getByText(account.name)).toBeInTheDocument();
|
||||||
|
|
||||||
screen.getByRole("button", { name: "Повторить" }).click();
|
screen.getByRole("button", { name: "Повторить" }).click();
|
||||||
await waitFor(() => expect(requestCount).toBe(3));
|
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
|
||||||
|
expect(requestCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a cached empty account list visible after a refresh error", async () => {
|
it("keeps a cached empty account list visible after a refresh error", async () => {
|
||||||
@@ -140,7 +152,7 @@ describe("AccountsPage", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
requestCount += 1;
|
requestCount += 1;
|
||||||
if (requestCount === 1) return HttpResponse.json([]);
|
if (requestCount === 1) return HttpResponse.json(resource([]));
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ code: "TBANK_UNAVAILABLE" },
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
@@ -163,3 +175,22 @@ describe("AccountsPage", () => {
|
|||||||
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
|
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps unknown membership loading instead of declaring no accounts", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json(resource(null)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const client = createTestQueryClient();
|
||||||
|
renderPage(client);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Нет инвестиционных счетов"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const AccountsPage: FC = () => {
|
|||||||
if (viewModel.state === "empty")
|
if (viewModel.state === "empty")
|
||||||
return (
|
return (
|
||||||
<AccountsPageEmpty
|
<AccountsPageEmpty
|
||||||
|
lastSuccessfulAt={viewModel.lastSuccessfulAt}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
|
sourceError={viewModel.sourceError}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
/>
|
/>
|
||||||
@@ -20,7 +22,9 @@ const AccountsPage: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<AccountsPageLoaded
|
<AccountsPageLoaded
|
||||||
accounts={viewModel.accounts}
|
accounts={viewModel.accounts}
|
||||||
|
lastSuccessfulAt={viewModel.lastSuccessfulAt}
|
||||||
refreshing={viewModel.refreshing}
|
refreshing={viewModel.refreshing}
|
||||||
|
sourceError={viewModel.sourceError}
|
||||||
refreshError={viewModel.refreshError}
|
refreshError={viewModel.refreshError}
|
||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,32 +2,42 @@ import {
|
|||||||
brokerageAccountCards,
|
brokerageAccountCards,
|
||||||
useBrokerageAccounts,
|
useBrokerageAccounts,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
function useAccountsPageViewModel() {
|
function useAccountsPageViewModel() {
|
||||||
const accounts = useBrokerageAccounts();
|
const accounts = useBrokerageAccounts();
|
||||||
if (!accounts.data) {
|
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
|
||||||
if (accounts.isFetching) return { state: "loading" as const };
|
if (
|
||||||
if (accounts.isError)
|
!accounts.data ||
|
||||||
|
(accounts.meta?.availability === "partial" && !accounts.data.length)
|
||||||
|
) {
|
||||||
|
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
|
||||||
|
return { state: "loading" as const };
|
||||||
|
if (accounts.isError || accounts.meta?.sync.state === "failed")
|
||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: apiErrorCode(accounts.error),
|
code: apiErrorCode(accounts.error),
|
||||||
retry: () => accounts.refetch(),
|
retry: sync.refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!accounts.data?.length)
|
if (!accounts.data?.length)
|
||||||
return {
|
return {
|
||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
refreshing: accounts.isFetching,
|
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||||
refreshError: accounts.isError,
|
refreshing: accounts.isFetching || sync.refreshing,
|
||||||
retry: () => accounts.refetch(),
|
sourceError: sync.sourceError,
|
||||||
|
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||||
|
retry: sync.refresh,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
accounts: brokerageAccountCards(accounts.data),
|
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||||
refreshing: accounts.isFetching,
|
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||||
refreshError: accounts.isError,
|
refreshing: accounts.isFetching || sync.refreshing,
|
||||||
retry: () => accounts.refetch(),
|
sourceError: sync.sourceError,
|
||||||
|
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||||
|
retry: sync.refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const AccountsPageLoading: FC = () => {
|
|||||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||||
<Skeleton height={32} width="30%" />
|
<Skeleton height={32} width="30%" />
|
||||||
<Skeleton height={20} width="45%" />
|
<Skeleton height={20} width="45%" />
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" aria-hidden="true">
|
<SimpleGrid cols={1} spacing="lg" aria-hidden="true">
|
||||||
{[0, 1].map((index) => (
|
{[0, 1].map((index) => (
|
||||||
<Card key={index} padding="xl" radius="lg" withBorder>
|
<Card key={index} padding="xl" radius="lg" withBorder>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
type AccountsRefreshStateProps = {
|
type AccountsRefreshStateProps = {
|
||||||
|
lastSuccessfulAt?: string | null;
|
||||||
|
sourceError?: boolean;
|
||||||
refreshing?: boolean;
|
refreshing?: boolean;
|
||||||
refreshError?: boolean;
|
refreshError?: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
@@ -11,9 +14,24 @@ type AccountsRefreshStateProps = {
|
|||||||
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||||
const { refreshing = false, refreshError = false, retry } = props;
|
const { refreshing = false, refreshError = false, retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!refreshing && !refreshError) return null;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{props.lastSuccessfulAt ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={retry}
|
||||||
|
disabled={refreshing}
|
||||||
|
variant="light"
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||||
|
</Button>
|
||||||
{refreshing ? (
|
{refreshing ? (
|
||||||
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
|
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -23,12 +41,15 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
|||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
{refreshError ? (
|
{refreshError ? (
|
||||||
<Alert color="orange">
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text>{t("accounts.refreshError")}</Text>
|
<Text>
|
||||||
<Button onClick={retry} variant="light">
|
{t(
|
||||||
{t("accounts.retry")}
|
props.sourceError
|
||||||
</Button>
|
? "investment.syncFailed"
|
||||||
|
: "accounts.refreshError",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { BondPage } from "./BondPage";
|
import { BondPage } from "./BondPage";
|
||||||
|
|
||||||
@@ -92,25 +93,27 @@ function setup(
|
|||||||
http.get("*/api/v1/bonds/:uid", () =>
|
http.get("*/api/v1/bonds/:uid", () =>
|
||||||
options.failBond
|
options.failBond
|
||||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||||
: HttpResponse.json(resolved),
|
: HttpResponse.json(resource(resolved)),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
|
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
|
||||||
requests.push(new URL(request.url));
|
requests.push(new URL(request.url));
|
||||||
return options.ambiguousTicker
|
return options.ambiguousTicker
|
||||||
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
|
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||||
: HttpResponse.json(resolved);
|
: HttpResponse.json(resource(resolved));
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
|
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
|
||||||
requests.push(new URL(request.url));
|
requests.push(new URL(request.url));
|
||||||
if (params.section === "metrics")
|
if (params.section === "metrics")
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
creditRating:
|
creditRating:
|
||||||
options.ratingState === "missing" ||
|
options.ratingState === "missing" ||
|
||||||
options.ratingState === "unavailable"
|
options.ratingState === "unavailable"
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
agency: "NCR",
|
agency: "NCR",
|
||||||
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
|
value:
|
||||||
|
options.ratingState === "withdrawn" ? null : "AAA.ru",
|
||||||
status:
|
status:
|
||||||
options.ratingState === "withdrawn"
|
options.ratingState === "withdrawn"
|
||||||
? "withdrawn"
|
? "withdrawn"
|
||||||
@@ -132,34 +135,45 @@ function setup(
|
|||||||
dateType: "MATDATE",
|
dateType: "MATDATE",
|
||||||
tradeAt: quote.priceAt,
|
tradeAt: quote.priceAt,
|
||||||
},
|
},
|
||||||
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
|
unavailable:
|
||||||
|
options.ratingState === "unavailable" ? ["rating"] : [],
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if (params.section === "quote")
|
if (params.section === "quote")
|
||||||
return failQuote
|
return failQuote
|
||||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
: HttpResponse.json(quote);
|
: HttpResponse.json(resource(quote));
|
||||||
if (params.section === "position")
|
if (params.section === "position")
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
|
resource(
|
||||||
options.noPosition
|
options.noPosition
|
||||||
? { ...position, quantity: "0", accounts: [] }
|
? { ...position, quantity: "0", accounts: [] }
|
||||||
: position,
|
: position,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (params.section === "candles")
|
if (params.section === "candles")
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
candles: [
|
candles: [
|
||||||
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
|
{
|
||||||
|
time: "2026-09-09T00:00:00Z",
|
||||||
|
close: "99.25",
|
||||||
|
isComplete: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
currency: null,
|
currency: null,
|
||||||
historyFrom: null,
|
historyFrom: null,
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if (options.failCoupons)
|
if (options.failCoupons)
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
{ code: "TBANK_UNAVAILABLE" },
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
);
|
);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
coupons: [
|
coupons: [
|
||||||
{
|
{
|
||||||
perBond: money("32.88"),
|
perBond: money("32.88"),
|
||||||
@@ -188,13 +202,15 @@ function setup(
|
|||||||
positionAt: null,
|
positionAt: null,
|
||||||
estimateUnavailable: false,
|
estimateUnavailable: false,
|
||||||
positionIsStale: false,
|
positionIsStale: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
const next = url.searchParams.has("cursor");
|
const next = url.searchParams.has("cursor");
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
hasNext: !next,
|
hasNext: !next,
|
||||||
@@ -202,7 +218,9 @@ function setup(
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: next ? "sold" : "dividend",
|
id: next ? "sold" : "dividend",
|
||||||
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
|
occurredAt: next
|
||||||
|
? "2022-01-02T10:00:00Z"
|
||||||
|
: "2025-01-02T10:00:00Z",
|
||||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
|
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
|
||||||
category: next ? "trade" : "income",
|
category: next ? "trade" : "income",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -212,7 +230,8 @@ function setup(
|
|||||||
price: next ? money("100") : null,
|
price: next ? money("100") : null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
@@ -300,9 +319,11 @@ describe("bond page", () => {
|
|||||||
it("keeps market content and sold-position history when coupons fail", async () => {
|
it("keeps market content and sold-position history when coupons fail", async () => {
|
||||||
setup({ noPosition: true, failCoupons: true });
|
setup({ noPosition: true, failCoupons: true });
|
||||||
await screen.findByText("98,125 %");
|
await screen.findByText("98,125 %");
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(
|
||||||
"Не удалось загрузить этот раздел",
|
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
|
||||||
);
|
'[role="status"]',
|
||||||
|
),
|
||||||
|
).toHaveAttribute("aria-live", "polite");
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -311,14 +332,36 @@ describe("bond page", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
it("preserves cached quote on refresh failure", async () => {
|
it("preserves cached quote on refresh failure", async () => {
|
||||||
const controls = setup();
|
const scopes: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
scopes.push(await request.json());
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
|
||||||
await screen.findByText("98,125 %");
|
await screen.findByText("98,125 %");
|
||||||
controls.failQuote();
|
controls.failQuote();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(
|
||||||
"последние полученные",
|
(await screen.findByText(/последние полученные/)).closest(
|
||||||
);
|
'[role="status"]',
|
||||||
|
),
|
||||||
|
).toHaveAttribute("aria-live", "polite");
|
||||||
expect(screen.getByText("98,125 %")).toBeVisible();
|
expect(screen.getByText("98,125 %")).toBeVisible();
|
||||||
|
expect(scopes).toEqual([
|
||||||
|
{
|
||||||
|
screen: "bond",
|
||||||
|
instrumentUid: uid,
|
||||||
|
accountId: "all",
|
||||||
|
candlePeriod: "year",
|
||||||
|
categories: ["trade"],
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
|
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
|
||||||
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
|
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
|
||||||
|
|||||||
@@ -55,13 +55,10 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={vm.refresh}
|
||||||
vm.bondState.retry();
|
disabled={vm.refreshing}
|
||||||
vm.quoteState.retry();
|
|
||||||
vm.metricsState.retry();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("stock.refreshPrice")}
|
{t("investment.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Group mt="md" gap="xs">
|
<Group mt="md" gap="xs">
|
||||||
@@ -80,8 +77,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
{vm.bondState.error || vm.quoteState.error ? (
|
{vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
|
||||||
<Alert role="alert" color="orange" mt="md">
|
<Alert role="status" aria-live="polite" color="orange" mt="md">
|
||||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -322,7 +319,9 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
{vm.coupons ? (
|
{vm.coupons ? (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
|
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
|
||||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
|
{t("stock.estimateUnavailable")}
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.upcoming.length ? (
|
{vm.upcoming.length ? (
|
||||||
vm.upcoming.map((coupon) => (
|
vm.upcoming.map((coupon) => (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { section } from "@/features/instrument-details";
|
import { section } from "@/features/instrument-details";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -58,6 +59,17 @@ function useBondPageVm() {
|
|||||||
...firstPage,
|
...firstPage,
|
||||||
cursor: cursors.at(-1),
|
cursor: cursors.at(-1),
|
||||||
});
|
});
|
||||||
|
const sync = useScreenSync(
|
||||||
|
{
|
||||||
|
screen: "bond",
|
||||||
|
instrumentUid: uid,
|
||||||
|
candlePeriod: period,
|
||||||
|
accountId: "all",
|
||||||
|
categories: categories[filter],
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
[bond, quote, candles, position, metrics, coupons, operations],
|
||||||
|
);
|
||||||
const setParam = (key: string, value: string) => {
|
const setParam = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
next.set(key, value);
|
next.set(key, value);
|
||||||
@@ -70,7 +82,7 @@ function useBondPageVm() {
|
|||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
} else void operations.refetch();
|
} else void sync.refresh();
|
||||||
};
|
};
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
@@ -79,31 +91,34 @@ function useBondPageVm() {
|
|||||||
price: Number(candle.close),
|
price: Number(candle.close),
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
return {
|
return {
|
||||||
|
refresh: sync.refresh,
|
||||||
|
refreshing: sync.refreshing,
|
||||||
|
refreshError: sync.transportError || sync.sourceError,
|
||||||
canonicalTo:
|
canonicalTo:
|
||||||
bond.data?.slug && bond.data.slug !== bondId
|
bond.data?.slug && bond.data.slug !== bondId
|
||||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||||
: null,
|
: null,
|
||||||
bond: bond.data,
|
bond: bond.data,
|
||||||
bondState: section(bond),
|
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
|
||||||
code: apiErrorCode(bond.error),
|
code: apiErrorCode(bond.error),
|
||||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||||
metrics: metrics.data,
|
metrics: metrics.data,
|
||||||
metricsState: section(metrics),
|
metricsState: section(metrics, sync.refresh),
|
||||||
quote: quote.data,
|
quote: quote.data,
|
||||||
quoteState: section(quote),
|
quoteState: section(quote, sync.refresh),
|
||||||
candles: candles.data,
|
candles: candles.data,
|
||||||
chartData,
|
chartData,
|
||||||
priceRows: chartData.toReversed(),
|
priceRows: chartData.toReversed(),
|
||||||
candlesState: section(candles),
|
candlesState: section(candles, sync.refresh),
|
||||||
period,
|
period,
|
||||||
setPeriod: (value: string) => setParam("period", value),
|
setPeriod: (value: string) => setParam("period", value),
|
||||||
averageLine: null,
|
averageLine: null,
|
||||||
hasIncompleteCandle:
|
hasIncompleteCandle:
|
||||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
positionState: section(position),
|
positionState: section(position, sync.refresh),
|
||||||
coupons: coupons.data,
|
coupons: coupons.data,
|
||||||
couponsState: section(coupons),
|
couponsState: section(coupons, sync.refresh),
|
||||||
upcoming:
|
upcoming:
|
||||||
coupons.data?.coupons
|
coupons.data?.coupons
|
||||||
.filter((event) => event.timing === "upcoming")
|
.filter((event) => event.timing === "upcoming")
|
||||||
@@ -121,7 +136,10 @@ function useBondPageVm() {
|
|||||||
),
|
),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: { ...section(operations), retry: retryOperations },
|
operationsState: {
|
||||||
|
...section(operations, sync.refresh),
|
||||||
|
retry: retryOperations,
|
||||||
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
operations.data?.items.map((item) => ({
|
operations.data?.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { BondsPage } from "./BondsPage";
|
import { BondsPage } from "./BondsPage";
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ describe("bond search", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/bonds", ({ request }) => {
|
http.get("*/api/v1/bonds", ({ request }) => {
|
||||||
query(new URL(request.url).searchParams.get("query"));
|
query(new URL(request.url).searchParams.get("query"));
|
||||||
return HttpResponse.json([
|
return HttpResponse.json(
|
||||||
|
resource([
|
||||||
{
|
{
|
||||||
name: "ОФЗ",
|
name: "ОФЗ",
|
||||||
ticker: "SU26238RMFS4",
|
ticker: "SU26238RMFS4",
|
||||||
@@ -32,7 +34,8 @@ describe("bond search", () => {
|
|||||||
perpetual: false,
|
perpetual: false,
|
||||||
priceAt: "2026-09-10T10:00:00Z",
|
priceAt: "2026-09-10T10:00:00Z",
|
||||||
},
|
},
|
||||||
]);
|
]),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
|||||||
{t("bond.searchSubtitle")}
|
{t("bond.searchSubtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={vm.retry}
|
||||||
|
disabled={vm.refreshing || vm.query.trim().length < 2}
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{t("investment.refresh")}
|
||||||
|
</Button>
|
||||||
|
{vm.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : null}
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("bond.searchLabel")}
|
label={t("bond.searchLabel")}
|
||||||
placeholder={t("bond.searchPlaceholder")}
|
placeholder={t("bond.searchPlaceholder")}
|
||||||
@@ -49,7 +59,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
|||||||
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.error ? (
|
{vm.error ? (
|
||||||
<Alert role="alert" color="orange">
|
<Alert role="status" aria-live="polite" color="orange">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
@@ -166,7 +176,9 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed">{t("bond.noSearchResults")}</Text>
|
<Text c="dimmed">
|
||||||
|
{t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
|
||||||
|
</Text>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { useBondSearch } from "@/entities/bond";
|
import { useBondSearch } from "@/entities/bond";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -11,15 +12,23 @@ function useBondsPageVm() {
|
|||||||
const query = params.get("q") ?? "";
|
const query = params.get("q") ?? "";
|
||||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||||
const result = useBondSearch(debounced);
|
const result = useBondSearch(debounced);
|
||||||
|
const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
|
||||||
const waiting = query.trim() !== debounced;
|
const waiting = query.trim() !== debounced;
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
||||||
loading: query.trim().length >= 2 && (waiting || result.isLoading),
|
loading:
|
||||||
error: result.isError,
|
query.trim().length >= 2 &&
|
||||||
|
(waiting ||
|
||||||
|
(!result.data &&
|
||||||
|
!result.isError &&
|
||||||
|
result.meta?.sync.state !== "failed")),
|
||||||
|
error: result.isError || sync.sourceError || sync.transportError,
|
||||||
|
refreshing: sync.refreshing,
|
||||||
|
partial: result.meta?.availability === "partial",
|
||||||
code: apiErrorCode(result.error),
|
code: apiErrorCode(result.error),
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void result.refetch();
|
void sync.refresh();
|
||||||
},
|
},
|
||||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { StockPage } from "./StockPage";
|
import { StockPage } from "./StockPage";
|
||||||
|
|
||||||
@@ -80,37 +81,42 @@ function setup(
|
|||||||
http.get("*/api/v1/stocks/:uid", () =>
|
http.get("*/api/v1/stocks/:uid", () =>
|
||||||
options.failStock
|
options.failStock
|
||||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||||
: HttpResponse.json(resolved),
|
: HttpResponse.json(resource(resolved)),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
|
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
|
||||||
requests.push(new URL(request.url));
|
requests.push(new URL(request.url));
|
||||||
return options.ambiguousTicker
|
return options.ambiguousTicker
|
||||||
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
|
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||||
: HttpResponse.json(resolved);
|
: HttpResponse.json(resource(resolved));
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
|
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
|
||||||
requests.push(new URL(request.url));
|
requests.push(new URL(request.url));
|
||||||
if (params.section === "quote")
|
if (params.section === "quote")
|
||||||
return failQuote
|
return failQuote
|
||||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
: HttpResponse.json(quote);
|
: HttpResponse.json(resource(quote));
|
||||||
if (params.section === "position")
|
if (params.section === "position")
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
|
resource(
|
||||||
options.noPosition
|
options.noPosition
|
||||||
? { ...position, quantity: "0", accounts: [] }
|
? { ...position, quantity: "0", accounts: [] }
|
||||||
: position,
|
: position,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (params.section === "candles")
|
if (params.section === "candles")
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
candles: [],
|
candles: [],
|
||||||
currency: "RUB",
|
currency: "RUB",
|
||||||
historyFrom: null,
|
historyFrom: null,
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if (params.section === "company")
|
if (params.section === "company")
|
||||||
return options.failCompany
|
return options.failCompany
|
||||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||||
: HttpResponse.json({
|
: HttpResponse.json(
|
||||||
|
resource({
|
||||||
description: "Описание компании",
|
description: "Описание компании",
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
@@ -123,8 +129,10 @@ function setup(
|
|||||||
],
|
],
|
||||||
unavailable: [],
|
unavailable: [],
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
});
|
}),
|
||||||
return HttpResponse.json({
|
);
|
||||||
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
dividends: [
|
dividends: [
|
||||||
{
|
{
|
||||||
perShare: money("32.88"),
|
perShare: money("32.88"),
|
||||||
@@ -149,13 +157,15 @@ function setup(
|
|||||||
positionAt: null,
|
positionAt: null,
|
||||||
estimateUnavailable: false,
|
estimateUnavailable: false,
|
||||||
positionIsStale: false,
|
positionIsStale: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
const next = url.searchParams.has("cursor");
|
const next = url.searchParams.has("cursor");
|
||||||
return HttpResponse.json({
|
return HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "all",
|
accountId: "all",
|
||||||
observedAt: quote.observedAt,
|
observedAt: quote.observedAt,
|
||||||
hasNext: !next,
|
hasNext: !next,
|
||||||
@@ -163,7 +173,9 @@ function setup(
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: next ? "sold" : "dividend",
|
id: next ? "sold" : "dividend",
|
||||||
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
|
occurredAt: next
|
||||||
|
? "2022-01-02T10:00:00Z"
|
||||||
|
: "2025-01-02T10:00:00Z",
|
||||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
||||||
category: next ? "trade" : "income",
|
category: next ? "trade" : "income",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -173,7 +185,8 @@ function setup(
|
|||||||
price: next ? money("100") : null,
|
price: next ? money("100") : null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
@@ -271,9 +284,11 @@ describe("stock page", () => {
|
|||||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(
|
||||||
"Не удалось загрузить этот раздел",
|
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
|
||||||
);
|
'[role="status"]',
|
||||||
|
),
|
||||||
|
).toHaveAttribute("aria-live", "polite");
|
||||||
});
|
});
|
||||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
||||||
const { requests } = setup();
|
const { requests } = setup();
|
||||||
@@ -308,14 +323,38 @@ describe("stock page", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("keeps the last quote on a refresh failure", async () => {
|
it("keeps the last quote on a refresh failure", async () => {
|
||||||
const controls = setup();
|
const scopes: unknown[] = [];
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
scopes.push(await request.json());
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ code: "TBANK_UNAVAILABLE" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const controls = setup({
|
||||||
|
path: `/stocks/${uid}?period=year&events=trades`,
|
||||||
|
});
|
||||||
await screen.findByText("300 ₽");
|
await screen.findByText("300 ₽");
|
||||||
controls.failQuote();
|
controls.failQuote();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(
|
||||||
"последние полученные",
|
(await screen.findByText(/последние полученные/)).closest(
|
||||||
);
|
'[role="status"]',
|
||||||
|
),
|
||||||
|
).toHaveAttribute("aria-live", "polite");
|
||||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||||
|
expect(scopes).toEqual([
|
||||||
|
{
|
||||||
|
screen: "stock",
|
||||||
|
instrumentUid: uid,
|
||||||
|
accountId: "all",
|
||||||
|
candlePeriod: "year",
|
||||||
|
categories: ["trade"],
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
it("offers recovery for an unavailable direct link", async () => {
|
it("offers recovery for an unavailable direct link", async () => {
|
||||||
setup({ failStock: true });
|
setup({ failStock: true });
|
||||||
|
|||||||
@@ -63,12 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={vm.refresh}
|
||||||
vm.stockState.retry();
|
disabled={vm.refreshing}
|
||||||
vm.quoteState.retry();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("stock.refreshPrice")}
|
{t("investment.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{vm.company?.description ? (
|
{vm.company?.description ? (
|
||||||
@@ -76,8 +74,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
{vm.company.description}
|
{vm.company.description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.stockState.error || vm.quoteState.error ? (
|
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
|
||||||
<Alert role="alert" color="orange" mt="md">
|
<Alert role="status" aria-live="polite" color="orange" mt="md">
|
||||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -164,7 +162,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
{vm.company ? (
|
{vm.company ? (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{vm.company.unavailable.length ? (
|
{vm.company.unavailable.length ? (
|
||||||
<Alert color="orange">
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm">{t("stock.partialCompany")}</Text>
|
<Text size="sm">{t("stock.partialCompany")}</Text>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
useStockQuote,
|
useStockQuote,
|
||||||
} from "@/entities/stock";
|
} from "@/entities/stock";
|
||||||
import { section } from "@/features/instrument-details";
|
import { section } from "@/features/instrument-details";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -58,6 +59,17 @@ function useStockPageVm() {
|
|||||||
...firstPage,
|
...firstPage,
|
||||||
cursor: cursors.at(-1),
|
cursor: cursors.at(-1),
|
||||||
});
|
});
|
||||||
|
const sync = useScreenSync(
|
||||||
|
{
|
||||||
|
screen: "stock",
|
||||||
|
instrumentUid: uid,
|
||||||
|
candlePeriod: period,
|
||||||
|
accountId: "all",
|
||||||
|
categories: categories[filter],
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
[stock, quote, candles, position, company, dividends, operations],
|
||||||
|
);
|
||||||
const setParam = (key: string, value: string) => {
|
const setParam = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
next.set(key, value);
|
next.set(key, value);
|
||||||
@@ -70,7 +82,7 @@ function useStockPageVm() {
|
|||||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
} else void operations.refetch();
|
} else void sync.refresh();
|
||||||
};
|
};
|
||||||
const chartData =
|
const chartData =
|
||||||
candles.data?.candles.map((candle) => ({
|
candles.data?.candles.map((candle) => ({
|
||||||
@@ -85,20 +97,23 @@ function useStockPageVm() {
|
|||||||
: null;
|
: null;
|
||||||
const companyData = company.data;
|
const companyData = company.data;
|
||||||
return {
|
return {
|
||||||
|
refresh: sync.refresh,
|
||||||
|
refreshing: sync.refreshing,
|
||||||
|
refreshError: sync.transportError || sync.sourceError,
|
||||||
canonicalTo:
|
canonicalTo:
|
||||||
stock.data?.slug && stock.data.slug !== stockId
|
stock.data?.slug && stock.data.slug !== stockId
|
||||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||||
: null,
|
: null,
|
||||||
stock: stock.data,
|
stock: stock.data,
|
||||||
stockState: section(stock),
|
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
|
||||||
code: apiErrorCode(stock.error),
|
code: apiErrorCode(stock.error),
|
||||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||||
quote: quote.data,
|
quote: quote.data,
|
||||||
quoteState: section(quote),
|
quoteState: section(quote, sync.refresh),
|
||||||
candles: candles.data,
|
candles: candles.data,
|
||||||
chartData,
|
chartData,
|
||||||
priceRows: chartData.toReversed(),
|
priceRows: chartData.toReversed(),
|
||||||
candlesState: section(candles),
|
candlesState: section(candles, sync.refresh),
|
||||||
period,
|
period,
|
||||||
setPeriod: (value: string) => setParam("period", value),
|
setPeriod: (value: string) => setParam("period", value),
|
||||||
averageLine:
|
averageLine:
|
||||||
@@ -106,11 +121,11 @@ function useStockPageVm() {
|
|||||||
hasIncompleteCandle:
|
hasIncompleteCandle:
|
||||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
positionState: section(position),
|
positionState: section(position, sync.refresh),
|
||||||
company: companyData,
|
company: companyData,
|
||||||
companyState: section(company),
|
companyState: section(company, sync.refresh),
|
||||||
dividends: dividends.data,
|
dividends: dividends.data,
|
||||||
dividendsState: section(dividends),
|
dividendsState: section(dividends, sync.refresh),
|
||||||
upcoming:
|
upcoming:
|
||||||
dividends.data?.dividends
|
dividends.data?.dividends
|
||||||
.filter((event) => event.timing === "upcoming")
|
.filter((event) => event.timing === "upcoming")
|
||||||
@@ -128,7 +143,10 @@ function useStockPageVm() {
|
|||||||
),
|
),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
operations: operations.data,
|
operations: operations.data,
|
||||||
operationsState: { ...section(operations), retry: retryOperations },
|
operationsState: {
|
||||||
|
...section(operations, sync.refresh),
|
||||||
|
retry: retryOperations,
|
||||||
|
},
|
||||||
eventRows:
|
eventRows:
|
||||||
operations.data?.items.map((item) => ({
|
operations.data?.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
|||||||
{vm.dividends ? (
|
{vm.dividends ? (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
|
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
|
||||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
<Alert color="orange" role="status" aria-live="polite">
|
||||||
|
{t("stock.estimateUnavailable")}
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.upcoming.length ? (
|
{vm.upcoming.length ? (
|
||||||
vm.upcoming.map((event) => (
|
vm.upcoming.map((event) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { StocksPage } from "./StocksPage";
|
import { StocksPage } from "./StocksPage";
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ describe("stock search", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/stocks", ({ request }) => {
|
http.get("*/api/v1/stocks", ({ request }) => {
|
||||||
query(new URL(request.url).searchParams.get("query"));
|
query(new URL(request.url).searchParams.get("query"));
|
||||||
return HttpResponse.json([
|
return HttpResponse.json(
|
||||||
|
resource([
|
||||||
{
|
{
|
||||||
name: "Сбербанк",
|
name: "Сбербанк",
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
@@ -30,7 +32,8 @@ describe("stock search", () => {
|
|||||||
price: { currency: "RUB", value: "306.12" },
|
price: { currency: "RUB", value: "306.12" },
|
||||||
priceAt: "2026-09-10T10:00:00Z",
|
priceAt: "2026-09-10T10:00:00Z",
|
||||||
},
|
},
|
||||||
]);
|
]),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
|||||||
{t("stock.searchSubtitle")}
|
{t("stock.searchSubtitle")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={vm.retry}
|
||||||
|
disabled={vm.refreshing || vm.query.trim().length < 2}
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{t("investment.refresh")}
|
||||||
|
</Button>
|
||||||
|
{vm.partial ? (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
) : null}
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("stock.searchLabel")}
|
label={t("stock.searchLabel")}
|
||||||
placeholder={t("stock.searchPlaceholder")}
|
placeholder={t("stock.searchPlaceholder")}
|
||||||
@@ -44,7 +54,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
|||||||
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{vm.error ? (
|
{vm.error ? (
|
||||||
<Alert role="alert" color="orange">
|
<Alert role="status" aria-live="polite" color="orange">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
@@ -142,7 +152,9 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
|
<Text c="dimmed">
|
||||||
|
{t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
|
||||||
|
</Text>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { useStockSearch } from "@/entities/stock";
|
import { useStockSearch } from "@/entities/stock";
|
||||||
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
@@ -11,15 +12,23 @@ function useStocksPageVm() {
|
|||||||
const query = params.get("q") ?? "";
|
const query = params.get("q") ?? "";
|
||||||
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
|
||||||
const result = useStockSearch(debounced);
|
const result = useStockSearch(debounced);
|
||||||
|
const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]);
|
||||||
const waiting = query.trim() !== debounced;
|
const waiting = query.trim() !== debounced;
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
||||||
loading: query.trim().length >= 2 && (waiting || result.isLoading),
|
loading:
|
||||||
error: result.isError,
|
query.trim().length >= 2 &&
|
||||||
|
(waiting ||
|
||||||
|
(!result.data &&
|
||||||
|
!result.isError &&
|
||||||
|
result.meta?.sync.state !== "failed")),
|
||||||
|
error: result.isError || sync.sourceError || sync.transportError,
|
||||||
|
refreshing: sync.refreshing,
|
||||||
|
partial: result.meta?.availability === "partial",
|
||||||
code: apiErrorCode(result.error),
|
code: apiErrorCode(result.error),
|
||||||
retry: () => {
|
retry: () => {
|
||||||
void result.refetch();
|
void sync.refresh();
|
||||||
},
|
},
|
||||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createQueryWrapper } from "@/test/query";
|
import { createQueryWrapper } from "@/test/query";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import {
|
import {
|
||||||
useAuthenticatedQueries,
|
useAuthenticatedQueries,
|
||||||
@@ -17,14 +18,16 @@ describe("useAuthenticatedQuery", () => {
|
|||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
requests();
|
requests();
|
||||||
return HttpResponse.json([
|
return HttpResponse.json(
|
||||||
|
resource([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
openedOn: "2024-08-13",
|
openedOn: "2024-08-13",
|
||||||
},
|
},
|
||||||
]);
|
]),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ describe("useAuthenticatedQuery", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
expect(requests).toHaveBeenCalledOnce();
|
expect(requests).toHaveBeenCalledOnce();
|
||||||
expect(result.current.data).toEqual([
|
expect(result.current.data?.data).toEqual([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { apiClient, configureAuthClient } from "./client";
|
import { apiClient, configureAuthClient } from "./client";
|
||||||
|
|
||||||
@@ -16,14 +17,14 @@ describe("apiClient authentication", () => {
|
|||||||
authorization.push(request.headers.get("Authorization") ?? "");
|
authorization.push(request.headers.get("Authorization") ?? "");
|
||||||
return authorization.length === 1
|
return authorization.length === 1
|
||||||
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
|
||||||
: HttpResponse.json([]);
|
: HttpResponse.json(resource([]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
|
||||||
|
|
||||||
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
|
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
expect(data).toEqual([]);
|
expect(data?.data).toEqual([]);
|
||||||
expect(refresh).toHaveBeenCalledOnce();
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
|
expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]);
|
||||||
});
|
});
|
||||||
@@ -76,7 +77,7 @@ describe("apiClient authentication", () => {
|
|||||||
if (++calls === 1)
|
if (++calls === 1)
|
||||||
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
|
||||||
await pending;
|
await pending;
|
||||||
return HttpResponse.json([{ id: "previous-user-account" }]);
|
return HttpResponse.json(resource([{ id: "previous-user-account" }]));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ type AuthHandlers = {
|
|||||||
clear: () => void;
|
clear: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
|
||||||
|
const latestReads = new Map<string, { sequence: number; pending: number }>();
|
||||||
|
function invalidateApiRequests(matches: (pathname: string) => boolean) {
|
||||||
|
for (const request of inFlight)
|
||||||
|
if (matches(request.pathname)) request.invalidated = true;
|
||||||
|
}
|
||||||
|
|
||||||
let auth: AuthHandlers | null = null;
|
let auth: AuthHandlers | null = null;
|
||||||
let hasAuthenticatedSession = false;
|
let hasAuthenticatedSession = false;
|
||||||
const authSessionListeners = new Set<() => void>();
|
const authSessionListeners = new Set<() => void>();
|
||||||
@@ -25,6 +32,7 @@ function setAuthenticatedSession(next: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function configureAuthClient(handlers: AuthHandlers | null) {
|
function configureAuthClient(handlers: AuthHandlers | null) {
|
||||||
|
if (!handlers) invalidateApiRequests(() => true);
|
||||||
auth = handlers;
|
auth = handlers;
|
||||||
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
||||||
}
|
}
|
||||||
@@ -39,12 +47,47 @@ function subscribeAuthenticatedSession(listener: () => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authenticatedFetch(request: Request): Promise<Response> {
|
async function authenticatedFetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
url.searchParams.delete("sync");
|
||||||
|
url.searchParams.sort();
|
||||||
|
const key = `${request.method} ${url}`;
|
||||||
|
const ticket = { pathname: url.pathname, invalidated: false };
|
||||||
|
const read =
|
||||||
|
request.method === "GET"
|
||||||
|
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
|
||||||
|
: null;
|
||||||
|
if (read) {
|
||||||
|
read.sequence += 1;
|
||||||
|
read.pending += 1;
|
||||||
|
latestReads.set(key, read);
|
||||||
|
}
|
||||||
|
const sequence = read?.sequence;
|
||||||
|
inFlight.add(ticket);
|
||||||
|
try {
|
||||||
|
return await fetchCurrent(
|
||||||
|
request,
|
||||||
|
() => !ticket.invalidated && (!read || sequence === read.sequence),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(ticket);
|
||||||
|
if (read && --read.pending === 0) latestReads.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrent(
|
||||||
|
request: Request,
|
||||||
|
isLatest: () => boolean,
|
||||||
|
): Promise<Response> {
|
||||||
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
const isAuthRequest = new URL(request.url).pathname.startsWith(
|
||||||
"/api/v1/auth/",
|
"/api/v1/auth/",
|
||||||
);
|
);
|
||||||
const session = auth;
|
const session = auth;
|
||||||
const generation = session?.generation?.();
|
const generation = session?.generation?.();
|
||||||
const isCurrent = () => generation === auth?.generation?.();
|
const isCurrent = () =>
|
||||||
|
(session?.generation
|
||||||
|
? generation === session.generation() &&
|
||||||
|
generation === auth?.generation?.()
|
||||||
|
: session === auth) && isLatest();
|
||||||
const token = session?.accessToken();
|
const token = session?.accessToken();
|
||||||
const withToken = (source: Request, value: string) => {
|
const withToken = (source: Request, value: string) => {
|
||||||
const headers = new Headers(source.headers);
|
const headers = new Headers(source.headers);
|
||||||
@@ -55,6 +98,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
|||||||
token && !isAuthRequest ? withToken(request, token) : request;
|
token && !isAuthRequest ? withToken(request, token) : request;
|
||||||
const retryRequest = authorized.clone();
|
const retryRequest = authorized.clone();
|
||||||
const response = await fetch(authorized);
|
const response = await fetch(authorized);
|
||||||
|
await response.clone().arrayBuffer();
|
||||||
if (!isAuthRequest && !isCurrent())
|
if (!isAuthRequest && !isCurrent())
|
||||||
throw new DOMException("Session changed", "AbortError");
|
throw new DOMException("Session changed", "AbortError");
|
||||||
if (
|
if (
|
||||||
@@ -78,6 +122,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
|||||||
const refreshedToken = auth.accessToken();
|
const refreshedToken = auth.accessToken();
|
||||||
if (!refreshedToken) return response;
|
if (!refreshedToken) return response;
|
||||||
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
const retried = await fetch(withToken(retryRequest, refreshedToken));
|
||||||
|
await retried.clone().arrayBuffer();
|
||||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||||
return retried;
|
return retried;
|
||||||
}
|
}
|
||||||
@@ -95,5 +140,6 @@ export {
|
|||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
configureAuthClient,
|
configureAuthClient,
|
||||||
getAuthenticatedSessionSnapshot,
|
getAuthenticatedSessionSnapshot,
|
||||||
|
invalidateApiRequests,
|
||||||
subscribeAuthenticatedSession,
|
subscribeAuthenticatedSession,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+436
-302
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -2,6 +2,17 @@ export {
|
|||||||
useAuthenticatedQueries,
|
useAuthenticatedQueries,
|
||||||
useAuthenticatedQuery,
|
useAuthenticatedQuery,
|
||||||
} from "./authenticated-query";
|
} from "./authenticated-query";
|
||||||
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
|
export {
|
||||||
|
apiClient,
|
||||||
|
apiQueryClient,
|
||||||
|
configureAuthClient,
|
||||||
|
invalidateApiRequests,
|
||||||
|
} from "./client";
|
||||||
export type { paths } from "./generated/schema";
|
export type { paths } from "./generated/schema";
|
||||||
export { apiErrorCode } from "./problem-details";
|
export { apiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
|
export {
|
||||||
|
invalidateResourceAccess,
|
||||||
|
unwrapResourceResult,
|
||||||
|
useResourceQuery,
|
||||||
|
} from "./resource-query";
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
type QueryFunctionContext,
|
||||||
|
type QueryKey,
|
||||||
|
type UseQueryOptions,
|
||||||
|
type UseQueryResult,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
|
import type { components } from "./generated/schema";
|
||||||
|
import { apiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
|
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||||
|
|
||||||
|
type Resource = {
|
||||||
|
data: unknown;
|
||||||
|
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||||
|
};
|
||||||
|
function unwrapResourceResult<T extends Resource, E>(
|
||||||
|
result: UseQueryResult<T, E>,
|
||||||
|
) {
|
||||||
|
const missing = result.isSuccess && result.data.data === null;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
transport: {
|
||||||
|
status: result.status,
|
||||||
|
fetchStatus: result.fetchStatus,
|
||||||
|
error: result.error,
|
||||||
|
},
|
||||||
|
data: result.data?.data ?? undefined,
|
||||||
|
meta: result.data?.meta,
|
||||||
|
isPending: result.isPending || missing,
|
||||||
|
isSuccess: result.isSuccess && !missing,
|
||||||
|
status: missing ? "pending" : result.status,
|
||||||
|
} as UseQueryResult<NonNullable<T["data"]>, E> & {
|
||||||
|
meta?: T["meta"];
|
||||||
|
transport: {
|
||||||
|
status: UseQueryResult<T, E>["status"];
|
||||||
|
fetchStatus: UseQueryResult<T, E>["fetchStatus"];
|
||||||
|
error: E | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateResourceAccess(
|
||||||
|
client: QueryClient,
|
||||||
|
group: string,
|
||||||
|
error: unknown,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
![
|
||||||
|
"NOT_FOUND",
|
||||||
|
"FORBIDDEN",
|
||||||
|
"TBANK_NOT_CONNECTED",
|
||||||
|
"INVALID_TBANK_TOKEN",
|
||||||
|
].includes(apiErrorCode(error) ?? "")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||||
|
accessEpochs.set(client, epochs);
|
||||||
|
epochs.set(group, (epochs.get(group) ?? 0) + 1);
|
||||||
|
const affected = {
|
||||||
|
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||||
|
cached.meta?.accessGroup === group,
|
||||||
|
};
|
||||||
|
void client.cancelQueries(affected);
|
||||||
|
for (const cached of client.getQueryCache().findAll(affected))
|
||||||
|
cached.setState({
|
||||||
|
data: undefined,
|
||||||
|
dataUpdatedAt: 0,
|
||||||
|
status: "error",
|
||||||
|
error: error as Error,
|
||||||
|
fetchStatus: "idle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observation changes transport mode, never the generated cache identity.
|
||||||
|
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
|
||||||
|
options: UseQueryOptions<T, E, T, K>,
|
||||||
|
) {
|
||||||
|
const client = useQueryClient();
|
||||||
|
const group =
|
||||||
|
typeof options.meta?.accessGroup === "string"
|
||||||
|
? options.meta.accessGroup
|
||||||
|
: undefined;
|
||||||
|
let epochs = accessEpochs.get(client);
|
||||||
|
if (!epochs) {
|
||||||
|
epochs = new Map();
|
||||||
|
accessEpochs.set(client, epochs);
|
||||||
|
}
|
||||||
|
const groupEpochs = epochs;
|
||||||
|
const queryFn = options.queryFn;
|
||||||
|
const guarded =
|
||||||
|
typeof queryFn === "function"
|
||||||
|
? async (context: QueryFunctionContext<K>) => {
|
||||||
|
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
|
||||||
|
const result = await queryFn(context);
|
||||||
|
if (group && epoch !== (groupEpochs.get(group) ?? 0))
|
||||||
|
throw new DOMException("Access changed", "AbortError");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
: queryFn;
|
||||||
|
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) invalidateResourceAccess(client, group, query.error);
|
||||||
|
}, [client, group, query.error]);
|
||||||
|
const observe = async (expectedStateVersion?: string) => {
|
||||||
|
const queryFn = guarded;
|
||||||
|
if (typeof queryFn !== "function") return;
|
||||||
|
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
|
||||||
|
return client.fetchQuery({
|
||||||
|
...options,
|
||||||
|
staleTime: 0,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async (context: QueryFunctionContext<K>) => {
|
||||||
|
const [method, path, input] = context.queryKey;
|
||||||
|
const init = input as
|
||||||
|
| { params?: { query?: Record<string, unknown> } }
|
||||||
|
| undefined;
|
||||||
|
const queryKey = [
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
...init,
|
||||||
|
params: {
|
||||||
|
...init?.params,
|
||||||
|
query: { ...init?.params?.query, sync: "observe" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as unknown as K;
|
||||||
|
const response = await queryFn({ ...context, queryKey });
|
||||||
|
if (
|
||||||
|
expectedStateVersion &&
|
||||||
|
response.meta.stateVersion !== expectedStateVersion
|
||||||
|
) {
|
||||||
|
// One more observational read; never turn a stale notification into an import.
|
||||||
|
const current = await queryFn({ ...context, queryKey });
|
||||||
|
if (current.meta.stateVersion !== expectedStateVersion)
|
||||||
|
throw new DOMException("Resource changed", "AbortError");
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return { ...unwrapResourceResult(query), observe };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
|
||||||
+1315
-335
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
|||||||
const ru = {
|
const ru = {
|
||||||
translation: {
|
translation: {
|
||||||
|
investment: {
|
||||||
|
refresh: "Обновить",
|
||||||
|
stale: "Данные требуют обновления",
|
||||||
|
syncFailed:
|
||||||
|
"Источник не удалось обновить. Сохранённые данные не изменены.",
|
||||||
|
partial: "Период загружен не полностью",
|
||||||
|
history: "История догружается",
|
||||||
|
sourceTime: "Данные на {{date}}",
|
||||||
|
},
|
||||||
appName: "WealthLedger",
|
appName: "WealthLedger",
|
||||||
navigation: {
|
navigation: {
|
||||||
cards: "Карты",
|
cards: "Карты",
|
||||||
@@ -623,6 +632,10 @@ const ru = {
|
|||||||
taxBoundary: "Налоговые операции не включены в этот результат.",
|
taxBoundary: "Налоговые операции не включены в этот результат.",
|
||||||
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
|
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
|
||||||
yearAgo: "Годом ранее",
|
yearAgo: "Годом ранее",
|
||||||
|
previousPeriod: "Предыдущий период",
|
||||||
|
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
||||||
|
comparisonFailed:
|
||||||
|
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||||
difference: "Разница",
|
difference: "Разница",
|
||||||
total: "Итого",
|
total: "Итого",
|
||||||
|
|||||||
+16
-5
@@ -1,4 +1,5 @@
|
|||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { resource } from "@/test/resource";
|
||||||
|
|
||||||
const authentication = {
|
const authentication = {
|
||||||
accessToken: "test-token",
|
accessToken: "test-token",
|
||||||
@@ -26,9 +27,12 @@ const handlers = [
|
|||||||
"*/api/v1/tbank-connection",
|
"*/api/v1/tbank-connection",
|
||||||
() => new HttpResponse(null, { status: 204 }),
|
() => new HttpResponse(null, { status: 204 }),
|
||||||
),
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json([])),
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json(resource([])),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
@@ -37,8 +41,10 @@ const handlers = [
|
|||||||
cashBalances: [],
|
cashBalances: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
items: [],
|
items: [],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -46,21 +52,26 @@ const handlers = [
|
|||||||
observedAt: "2026-08-14T00:00:00Z",
|
observedAt: "2026-08-14T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
events: [],
|
events: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () =>
|
||||||
HttpResponse.json({
|
HttpResponse.json(
|
||||||
|
resource({
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
period: { from: "2026-01-01", to: "2026-08-14" },
|
period: { from: "2026-01-01", to: "2026-08-14" },
|
||||||
totalsByCurrency: [],
|
totalsByCurrency: [],
|
||||||
calculatedAt: "2026-08-14T00:00:00Z",
|
calculatedAt: "2026-08-14T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export { handlers };
|
export { handlers };
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { paths } from "@/shared/api";
|
||||||
|
|
||||||
|
type Meta =
|
||||||
|
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"];
|
||||||
|
function resource<T>(data: T, meta: Partial<Meta> = {}) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
resourceKey: "fixture",
|
||||||
|
dataRevision: "1",
|
||||||
|
stateVersion: "1",
|
||||||
|
availability: data === null ? "missing" : "complete",
|
||||||
|
freshness: "fresh",
|
||||||
|
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||||||
|
sync: { id: null, state: "idle", errorCode: null },
|
||||||
|
...meta,
|
||||||
|
} satisfies Meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resource };
|
||||||
Reference in New Issue
Block a user