fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s

Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
This commit is contained in:
2026-09-27 11:38:20 +03:00
parent b34c1c860a
commit f6db28defa
50 changed files with 2017 additions and 215 deletions
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
}
describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself",
async (path) => {
authenticated = path === "/accounts";
let failing = true;
const RecoverableRoute: FC = () => {
if (failing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteErrorBoundary>
<RecoverableRoute />
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
const action = screen.getByRole("link", { name: "Продолжить" });
failing = false;
fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument();
},
);
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
authenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect(
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
).toBeInTheDocument();
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Перезагрузить страницу" }),
).toHaveAttribute("href", "/accounts");
});
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
authenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/login"]}>
<RouteErrorBoundary>
<Suspense fallback={<div>loading chunk</div>}>
<Chunk />
</Suspense>
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
expect(load).toHaveBeenCalledTimes(1);
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let intercepted = true;
const preventNavigation = (event: MouseEvent) => {
intercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(intercepted).toBe(false);
});
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
<Button component={Link} to={to}>
{t("routeError.action")}
</Button>
<Button component="a" href={to} variant="default">
{t("routeError.reload")}
</Button>
</Stack>
);
};
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { pathname, search } = useLocation();
const { pathname, search, key } = useLocation();
const { user } = useAuth();
return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
<Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children}
</Boundary>
);
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMonth(value: string | null) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
import { bankPeriod } from "./period";
afterEach(() => vi.useRealTimers());
it.each([
["0001-01", "0001-01-01", "0001-01-31"],
["0099-12", "0099-12-01", "0099-12-31"],
["2026-09", "2026-09-01", "2026-09-30"],
["2024-02", "2024-02-01", "2024-02-29"],
["0004-02", "0004-02-01", "0004-02-29"],
])("keeps the ISO year for legacy month %s", (month, from, to) => {
expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
});
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
}
});
+4 -2
View File
@@ -1,10 +1,12 @@
import dayjs from "dayjs";
import { z } from "zod";
import { bankMonth } from "./form-validation";
const calendarDate = z.string().date();
const ISO_DATE_LENGTH = 10;
function bankPeriod(params: URLSearchParams) {
const month = bankMonth(params.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = params.get("from");
const to = params.get("to");
return from &&
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
? { from, to }
: {
from: `${month}-01`,
to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"),
to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH),
};
}
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
];
let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => {
release = resolve;
});
const patches: { id: unknown; body: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map((category) =>
category.id === "a" && reads > 1
? {
...category,
name: reads >= 3 ? "Первая обновлена" : category.name,
version: 2,
}
: category,
),
);
}),
http.patch(
"*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => {
patches.push({ id: params.id, body: await request.json() });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(categories[1]);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await waitFor(() => expect(reads).toBe(3));
fireEvent.click(
screen.getAllByRole("button", { name: "Переименовать" })[1],
);
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" },
});
await act(async () => {
release();
await reloaded;
});
await screen.findByText("Первая обновлена");
await waitFor(() =>
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Название категории/)).toHaveValue(
"Новая вторая",
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } },
{ id: "b", body: { name: "Новая вторая", version: 4 } },
]),
);
});
it.each(["Отмена", "Сохранить"])(
"clears reloaded recovery after %s",
async (action) => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
let version = 1;
let writes = 0;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version },
]),
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({});
}),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Переименовать" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await screen.findByRole("button", {
name: /Использовать актуальную версию/,
});
fireEvent.click(screen.getByRole("button", { name: action }));
await screen.findByRole("button", { name: "Добавить категорию" });
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument();
},
);
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
@@ -8,7 +8,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { type FC, useState } from "react";
import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
});
if (result) props.onCreated?.(result.id);
}
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} catch (failure) {
setError(failure);
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="subtle"
onClick={() => {
editSession.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={async () => {
const session = editSession.current;
const response = await categories.refetch();
if (session !== editSession.current) return;
if (response.error) setError(response.error);
else
setLatest(
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)}
</>
) : null}
{latest && (
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={() => {
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
@@ -2,11 +2,13 @@ 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 { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() {
configureAuthClient({
@@ -16,6 +18,207 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (string | null)[] = [];
let unavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return unavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
expect(result.current.accounts.isSuccess).toBe(true),
);
unavailable = true;
await act(() => result.current.accounts.refetch());
}
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
unavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.stateVersion).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
cached ? [null, null, "observe"] : [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
let recovering = false;
let changed = false;
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && changed && !request.signal.aborted)
return HttpResponse.json(resource([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return changed
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
recovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "old" }));
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise<void>;
act(() => {
recovery = result.current.sync.retry();
});
if (pending) await waitFor(() => expect(recovering).toBe(true));
await act(async () => {
changed = true;
if (boundary === "logout") {
configureAuthClient(null);
queryClient.clear();
} else if (boundary === "session") {
auth();
queryClient.clear();
} else if (boundary === "access") {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
} else
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
release();
await recovery;
});
if (boundary === "connection")
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }),
}),
);
unsubscribe();
},
);
it.each([false, true])(
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (string | null)[] = [];
let polls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ id: "recover" }, { status: 202 }),
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
resources: [resource([]).meta],
});
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
if (retryStatus) {
await waitFor(() =>
expect(result.current.sync.transportError).toBe(true),
);
await act(() => result.current.sync.retry());
}
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, "observe"]);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (string | null)[] = [];
@@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
apiErrorCode,
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
queryKey: QueryKey;
meta?: ResourceMeta;
transport: {
status: "pending" | "success" | "error";
fetchStatus: "idle" | "fetching" | "paused";
};
observe: (version?: string) => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
latest.set(dependency.resourceKey, dependency);
}
for (const resource of resources) {
if (!resource.meta) continue;
if (!resource.meta) {
if (resource.transport.fetchStatus !== "idle") continue;
const completed = statuses.find(
(status) =>
requested?.scope === scopeKey &&
status.data?.id === requested.id &&
status.data.state !== "running",
)?.data;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (completed && observed.current.get(key) !== completed.id) {
observed.current.set(key, completed.id);
void resource.observe().catch(() => undefined);
}
continue;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
);
void resource.observe(own?.stateVersion).catch(() => undefined);
}
}, [client, statuses, resources]);
}, [client, statuses, resources, requested, scopeKey]);
const refreshing =
(currentMutation && mutation.isPending) ||
statuses.some(
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
}
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
(resource) => resource.transport.status === "error",
);
const failedStatuses = statuses.filter((status) => status.isError);
if (failed.length || failedStatuses.length) {
await Promise.allSettled([
...failed.map((resource) => resource.observe()),
...failedStatuses.map((status) => status.refetch()),
]);
} else await refresh();
};
return {
refresh,
retry,
refreshing,
transportError:
(currentMutation && mutation.isError) ||
@@ -1,11 +1,18 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
import { TbankConnectionSection } from "./TbankConnectionSection";
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
@@ -78,6 +85,74 @@ function seededClient() {
}
describe("TbankConnectionSection", () => {
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
let finishRefetch: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishRefetch = resolve;
});
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", async () => {
reads += 1;
if (reads === 1) {
await response;
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}
return HttpResponse.json(status);
}),
);
const client = seededClient();
client.setQueryData(tbankConnectionQueryOptions().queryKey, status);
renderSection(client);
await waitFor(() => expect(reads).toBe(1));
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
expect(
await screen.findByText(/Не удалось обновить статус подключения/),
).toBeVisible();
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual(
status,
);
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument(),
);
expect(token).toHaveValue("replacement-draft");
});
it("shows a cold-start error without a form and recovers by retrying status", async () => {
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", () => {
reads += 1;
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ connected: false, connectedAt: null });
}),
);
renderSection(createTestQueryClient());
expect(
await screen.findByText(/Не удалось загрузить статус подключения/),
).toBeVisible();
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
expect(reads).toBe(2);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
renderSection(client);
@@ -225,7 +300,12 @@ describe("TbankConnectionSection", () => {
});
});
it("preserves connected status and financial cache when deletion fails", async () => {
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
let deletes = 0;
let finishDelete: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishDelete = resolve;
});
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
connectedAt: "2026-08-14T00:00:00Z",
}),
),
http.delete("*/api/v1/tbank-connection", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
http.delete("*/api/v1/tbank-connection", async () => {
deletes += 1;
if (deletes === 1) await response;
return deletes === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: new HttpResponse(null, { status: 204 });
}),
);
const client = seededClient();
renderSection(client);
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
const dialog = await screen.findByRole("dialog", {
name: "Удалить подключение?",
});
await waitFor(() =>
expect(dialog.contains(document.activeElement)).toBe(true),
);
const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ });
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(confirm).toBeDisabled());
expect(
within(dialog).getByRole("button", { name: "Закрыть" }),
).toBeDisabled();
// JSDOM does not move focus when a focused button becomes disabled.
document.body.tabIndex = -1;
document.body.focus();
document.body.removeAttribute("tabindex");
expect(document.activeElement).toBe(document.body);
finishDelete?.();
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз.");
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Не удалось выполнить запрос. Попробуйте ещё раз.",
);
expect(confirm).toHaveFocus();
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(screen.getByText("Не подключено")).toBeVisible();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(deletes).toBe(2);
});
});
@@ -11,7 +11,7 @@ const TbankConnectionSection: FC = () => {
if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />;
return <TbankConnectionSectionLoaded {...viewModel} />;
};
export { TbankConnectionSection };
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(),
});
if (status.isPending) return { state: "loading" as const };
if (status.isError)
if (!status.data) {
if (status.isPending) return { state: "loading" as const };
return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data };
}
return {
state: "loaded" as const,
status: status.data,
refreshing: status.isFetching,
refreshError: status.isError,
retry: () => status.refetch(),
};
}
export { useTbankConnectionViewModel };
@@ -8,7 +8,7 @@ import {
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { apiErrorCode, apiQueryClient } from "@/shared/api";
@@ -19,6 +19,9 @@ import {
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
};
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const deleteButton = useRef<HTMLButtonElement>(null);
const [requestError, setRequestError] = useState<string | null>(null);
const {
register,
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
});
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
},
);
const pending = update.isPending || remove.isPending;
const connected = status.connected;
useEffect(() => {
if (deleteOpened && remove.isError) deleteButton.current?.focus();
}, [deleteOpened, remove.isError]);
return (
<Stack mt="xl">
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Text size="sm" c="dimmed">
{t("tbank.hint")}
</Text>
{props.refreshing && (
<Text role="status" size="sm" c="dimmed">
{t("tbank.refreshing")}
</Text>
)}
{props.refreshError && (
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("tbank.refreshError")}</Text>
<Button
variant="default"
onClick={props.retry}
disabled={props.refreshing || pending}
>
{t("tbank.retry")}
</Button>
</Stack>
</Alert>
)}
<form
onSubmit={handleSubmit(({ token }) => {
setRequestError(null);
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Button
variant="outline"
color="red"
onClick={() => setDeleteOpened(true)}
onClick={() => {
remove.reset();
setDeleteOpened(true);
}}
>
{t("tbank.delete")}
</Button>
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
>
<Stack>
<Text>{t("tbank.deleteText")}</Text>
{remove.isError && (
<Alert color="red">{t("tbank.requestError")}</Alert>
)}
<Button
ref={deleteButton}
color="red"
loading={remove.isPending}
disabled={pending}
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it.each([
"from=2099-02-01&to=2099-02-28",
"from=2099-02-01",
"to=2099-02-28",
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01",
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
sourceError: false,
transportError: false,
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined, {});
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
},
[events],
);
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.refresh();
void sync.retry();
},
};
if (!events.data) return { state: "loading" };
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
void sync.refresh();
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
});
},
onReset,
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,9 +1,9 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void };
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
action={
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -6,6 +6,7 @@ import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
}
describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
to: "2024-03-01",
},
]),
);
});
it("requests a multi-year period from the URL without resetting it", async () => {
const requested: string[] = [];
server.use(
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
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,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }),
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
{ ...resource(analytics).meta, role: "selected" as const },
];
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",
},
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
retry: sync.retry,
form,
onSubmit,
onReset,
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
@@ -251,9 +252,10 @@ function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
commission: { currency: "RUB", value: "1" },
};
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
function configureTestAuth() {
configureAuthClient({
accessToken: () => "token",
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
});
describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
async (accountId) => {
configureTestAuth();
const searches: URLSearchParams[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
items: [operation],
nextCursor: "next",
hasNext: true,
}),
);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2026-02-28&categories=trade",
"?from=2026-02-30&categories=trade",
"?from=2026-03-01&to=2026-02-28&categories=trade",
"?from=2026-02-01&categories=trade",
"?to=2026-02-28&categories=trade",
].map((search) => `/accounts/${accountId}/operations${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull();
for (const expected of [
"?categories=trade",
"?categories=trade",
"?from=2026-02-01&categories=trade",
]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
expect(searches.at(-1)?.get("to")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
await screen.findByLabelText("Период");
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
await screen.findByLabelText("Период");
await waitFor(() =>
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
);
expect(searches.at(-1)?.get("from")).toBeNull();
expect(searches.at(-1)?.get("cursor")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
);
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(searches.map((query) => query.get("from"))).not.toContain(
"badT00:00:00.000Z",
);
expect(
searches.every((query) => query.get("categories") === "trade"),
).toBe(true);
},
);
it.each(["account-1", "all"])(
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource({ items: [], hasNext: false, nextCursor: null }),
),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/operations?from=2026-02-01`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it("resets cursor pagination when filters change", async () => {
configureTestAuth();
const searches: string[] = [];
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
});
});
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, {
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual([
{
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: "trade",
limit: "100",
},
]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "operations",
accountId: "account-1",
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: ["trade"],
limit: 100,
},
]),
);
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
configureTestAuth();
server.use(
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
sourceError?: boolean;
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
);
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
appliedFrom,
appliedTo,
urlFrom,
urlTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
useEffect(() => {
setDraftFrom(searchParams.get("from"));
setDraftTo(searchParams.get("to"));
setPagination((current) =>
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
);
}, [scopeKey]);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
),
);
}, [searchParams]);
}, [appliedFrom, appliedTo, searchParams]);
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
exact: true,
});
resetCursors();
} else void sync.refresh();
} else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
if (operations.isLoading) return { state: "loading" };
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
return { state: "error", retry, onReset };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
items: operations.data.items,
hasNext: Boolean(operations.data.hasNext && nextCursor),
hasPrevious: Boolean(cursors.length),
canApply: isDirty,
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
});
resetCursors();
},
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
onReset,
onNext: () => {
if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
};
};
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
function dateStart(value: string | null) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
@@ -1,4 +1,4 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
error
title={t("ledger.errorTitle")}
text={t("ledger.errorText")}
action={<Button onClick={retry}>{t("ledger.retry")}</Button>}
action={
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: sync.refresh,
retry: sync.retry,
};
return { state: "loading" as const };
}
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
if (
portfolio.meta?.availability === "complete" &&
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
expect(modes).toEqual([null, "observe"]);
});
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
expect(requestCount).toBe(2);
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
return {
state: "error" as const,
code: apiErrorCode(accounts.error),
retry: sync.refresh,
retry: sync.retry,
};
}
if (!accounts.data?.length)
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
return {
state: "loaded" as const,
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
}
+8 -8
View File
@@ -82,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
} else void sync.retry();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -99,26 +99,26 @@ function useBondPageVm() {
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.refresh),
metricsState: section(metrics, sync.retry),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
coupons: coupons.data,
couponsState: section(coupons, sync.refresh),
couponsState: section(coupons, sync.retry),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
@@ -137,7 +137,7 @@ function useBondPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
...section(operations, sync.retry),
retry: retryOperations,
},
eventRows:
+1 -1
View File
@@ -28,7 +28,7 @@ function useBondsPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+24 -1
View File
@@ -46,6 +46,7 @@ const operation = {
};
function setup(
options: {
month?: string;
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
@@ -101,7 +102,9 @@ function setup(
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<MemoryRouter
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
</Routes>
@@ -112,6 +115,26 @@ function setup(
return { requests, summaryRequests, client };
}
describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
["2026-09", "2026-09-30"],
["2024-02", "2024-02-29"],
])(
"sends the same legacy month %s range to history and summary",
async (month, to) => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
expect(query.get("to")).toBe(to);
expect(query.has("month")).toBe(false);
}
},
);
it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
const LocationProbe = () => {
const location = useLocation();
@@ -72,6 +73,7 @@ function setup(
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
@@ -118,7 +120,7 @@ function setup(
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: [
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
@@ -215,6 +217,63 @@ function setup(
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible();
});
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
@@ -290,6 +349,31 @@ describe("stock page", () => {
),
).toHaveAttribute("aria-live", "polite");
});
it("recovers an initially missing company section without reloading successful sections", async () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
}),
);
}),
);
const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]);
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: metric.currency ?? "RUB",
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
? metric.currency === "RUB"
? "₽"
: (metric.currency ?? t("stock.currencyUnknown"))
: metric.unit === "percent"
? "%"
: "×"}
+8 -8
View File
@@ -82,7 +82,7 @@ function useStockPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
} else void sync.retry();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -105,15 +105,15 @@ function useStockPageVm() {
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
@@ -121,11 +121,11 @@ function useStockPageVm() {
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
company: companyData,
companyState: section(company, sync.refresh),
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.refresh),
dividendsState: section(dividends, sync.retry),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
@@ -144,7 +144,7 @@ function useStockPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
...section(operations, sync.retry),
retry: retryOperations,
},
eventRows:
@@ -28,7 +28,7 @@ function useStocksPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+96 -15
View File
@@ -1124,9 +1124,41 @@ export interface components {
instruments: components["schemas"]["IncomeInstrumentDto"][];
entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
};
IncomeDependencyDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @enum {string} */
role: "selected" | "previous" | "year";
};
IncomeAnalyticsMetaDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][];
};
IncomeAnalyticsResourceDto: {
data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
@@ -1515,20 +1547,50 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
SyncCandleRangeDto: {
/** Format: date-time */
from: string;
/** Format: date-time */
to: string;
};
InvestmentScreenDto: {
/** @enum {string} */
screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond";
TimestampInvestmentScreenDto: {
accountId?: string;
/** Format: date-time */
from?: string;
/** Format: date-time */
to?: string;
/** Format: uuid */
instrumentUid?: string;
query?: string;
@@ -1537,11 +1599,26 @@ export interface components {
candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
};
TbankSyncAcceptedDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["InvestmentScreenDto"];
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */
now: string;
};
@@ -1553,7 +1630,7 @@ export interface components {
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["InvestmentScreenDto"] | null;
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */
now: string;
/** @enum {string} */
@@ -3525,7 +3602,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string;
};
header?: never;
@@ -3661,7 +3740,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string;
};
header?: never;
@@ -3731,9 +3812,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
@@ -3801,9 +3882,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
@@ -5160,7 +5241,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["InvestmentScreenDto"];
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
};
};
responses: {
+14 -3
View File
@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query";
import { useEffect } from "react";
import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import type { components } from "./generated/schema";
import { apiErrorCode } from "./problem-details";
@@ -107,8 +108,14 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
}, [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 });
if (
!query.isEnabled ||
!getAuthenticatedSessionSnapshot() ||
typeof queryFn !== "function"
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
return client.fetchQuery({
...options,
staleTime: 0,
@@ -144,7 +151,11 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
},
});
};
return { ...unwrapResourceResult(query), observe };
return {
...unwrapResourceResult(query),
queryKey: options.queryKey,
observe,
};
}
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
+285 -35
View File
@@ -2668,6 +2668,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2677,6 +2678,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2878,6 +2880,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2887,6 +2890,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2984,7 +2988,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
@@ -2994,7 +2998,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": {
"format": "date",
"type": "string"
@@ -3100,7 +3104,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
@@ -3110,7 +3114,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": {
"format": "date",
"type": "string"
@@ -5001,7 +5005,31 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvestmentScreenDto"
"oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
}
}
}
@@ -6878,6 +6906,129 @@
"entries"
]
},
"IncomeDependencyDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"role": {
"type": "string",
"enum": [
"selected",
"previous",
"year"
]
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"role"
]
},
"IncomeAnalyticsMetaDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"dependencies": {
"description": "Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.",
"type": "array",
"items": {
"$ref": "#/components/schemas/IncomeDependencyDto"
}
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"dependencies"
]
},
"IncomeAnalyticsResourceDto": {
"type": "object",
"properties": {
@@ -6891,7 +7042,7 @@
]
},
"meta": {
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
"$ref": "#/components/schemas/IncomeAnalyticsMetaDto"
}
},
"required": [
@@ -8677,6 +8828,62 @@
"token"
]
},
"IncomeInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"income"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now."
}
},
"required": [
"accountId",
"screen"
]
},
"EventsInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"events"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end."
}
},
"required": [
"accountId",
"screen"
]
},
"SyncCandleRangeDto": {
"type": "object",
"properties": {
@@ -8694,34 +8901,12 @@
"to"
]
},
"InvestmentScreenDto": {
"TimestampInvestmentScreenDto": {
"type": "object",
"properties": {
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"income",
"events",
"stocks",
"bonds",
"stock",
"bond"
]
},
"accountId": {
"type": "string"
},
"from": {
"type": "string",
"format": "date-time"
},
"to": {
"type": "string",
"format": "date-time"
},
"instrumentUid": {
"type": "string",
"format": "uuid"
@@ -8759,6 +8944,28 @@
"type": "number",
"minimum": 1,
"maximum": 1000
},
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"stocks",
"bonds",
"stock",
"bond"
]
},
"from": {
"type": "string",
"format": "date-time",
"description": "Start instant for operations."
},
"to": {
"type": "string",
"format": "date-time",
"description": "End instant for operations."
}
},
"required": [
@@ -8773,7 +8980,31 @@
"format": "uuid"
},
"scope": {
"$ref": "#/components/schemas/InvestmentScreenDto"
"oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
},
"now": {
"type": "string",
@@ -8813,13 +9044,32 @@
"format": "uuid"
},
"scope": {
"nullable": true,
"type": "object",
"allOf": [
"oneOf": [
{
"$ref": "#/components/schemas/InvestmentScreenDto"
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
]
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
},
"nullable": true
},
"now": {
"type": "string",
+7
View File
@@ -269,6 +269,7 @@ const ru = {
},
},
stock: {
currencyUnknown: "валюта неизвестна",
searchTitle: "Акции",
searchSubtitle:
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
@@ -636,6 +637,7 @@ const ru = {
comparisonPending: "Период сравнения ещё не загружен полностью.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
yearUnavailable: "Сравнение с годом ранее недоступно.",
difference: "Разница",
total: "Итого",
@@ -659,6 +661,10 @@ const ru = {
},
settings: { title: "Настройки" },
tbank: {
close: "Закрыть",
refreshError:
"Не удалось обновить статус подключения. Показаны последние полученные данные.",
refreshing: "Обновляем статус подключения…",
title: "T-Bank Invest",
disconnected: "Не подключено",
connected: "Подключено с {{date}}",
@@ -683,6 +689,7 @@ const ru = {
title: "Не удалось открыть страницу",
text: "Перейдите в безопасный раздел и попробуйте снова.",
action: "Продолжить",
reload: "Перезагрузить страницу",
},
states: {
loadingTitle: "Загрузка",
+27
View File
@@ -177,6 +177,33 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
export const SecondaryText: Story = {
name: "Вторичный текст",
render: () => (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{[
"var(--wl-color-surface)",
"var(--wl-color-canvas)",
"var(--wl-color-subtle)",
...Object.keys(designTokens.color.palettes)
.filter((palette) => palette !== "dark")
.flatMap((palette) => [
`var(--mantine-color-${palette}-0)`,
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
]),
].map((background) => (
<Paper key={background} p="md" withBorder style={{ background }}>
<Text size="sm" c="dimmed">
22.09.2026 · Синтетические данные
</Text>
<Text size="xs" c="dimmed">
Не удалось обновить данные. Сохранён предыдущий результат.
</Text>
</Paper>
))}
</SimpleGrid>
),
};
export const Typography: Story = {
name: "Типографика",
render: () => <TypographyView />,
+8 -3
View File
@@ -9,16 +9,21 @@ function luminance(color: string) {
);
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
describe("financial text contrast", () => {
it.each(["positive", "negative"] as const)(
"keeps %s readable on journal, position and tinted card surfaces",
describe("semantic text contrast", () => {
it.each(["positive", "negative", "muted"] as const)(
"keeps %s readable on canvas, white, subtle and tinted surfaces",
(tone) => {
const role = tokens.color.roles[tone];
const palettes = tokens.color.palettes;
const foreground =
palettes[role.color as keyof typeof palettes][role.shade];
const surfaces = [
"#ffffff",
palettes.gray[0],
palettes.gray[2],
...Object.entries(palettes)
.filter(([name]) => name !== "dark")
.map(([, palette]) => palette[0]),
...Object.entries(palettes)
.filter(([name]) => name !== "dark")
.map(