chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+200 -179
View File
@@ -1,16 +1,19 @@
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 { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
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";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
@@ -22,64 +25,66 @@ const LocationProbe = () => {
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const stock = {
instrumentUid: uid,
name: "Тестовая акция",
ticker: "TEST",
shareType: "SHARE_TYPE_COMMON",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const money = (value: string) => ({ currency: "RUB", value });
const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
currency: "RUB",
historyFrom: null,
instrumentUid: uid,
logoName: null,
lot: 10,
name: "Тестовая акция",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
absoluteReturn: money("1000"),
account: { id: "main", name: "Основной" },
averagePurchasePrice: money("200"),
currentValue: money("3000"),
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
averagePurchasePrice: money("200"),
currentValue: money("3000"),
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
quantity: "10",
returnPercent: "50",
};
const quote = {
change: money("10"),
changePercent: "3.448276",
observedAt: "2026-09-10T10:00:00Z",
previousClose: money("290"),
previousCloseDate: "2026-09-09",
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
change: money("10"),
changePercent: "3.448276",
previousCloseDate: "2026-09-09",
previousClose: money("290"),
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
ambiguousTicker?: boolean;
cursorError?: string;
failCompany?: boolean;
failStock?: boolean;
cursorError?: string;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
let isFailQuote = false;
let cursorError = options.cursorError;
let operationPayment = "200";
server.use(
@@ -97,109 +102,117 @@ function setup(
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
return isFailQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
? { ...position, accounts: [], quantity: "0" }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json(
resource({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
description: "Описание компании",
metrics: options.metrics ?? [
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.6,
},
],
observedAt: quote.observedAt,
unavailable: [],
}
/* eslint-enable unicorn/no-null */)),
);
return HttpResponse.json(
resource({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
dividends: [
{
estimatedAmount: options.noPosition ? null : money("328.8"),
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
perShare: money("32.88"),
recordDate: "2026-10-13",
timing: "upcoming",
},
{
estimatedAmount: null,
lastBuyDate: null,
paymentDate: null,
perShare: money("20"),
recordDate: "2025-10-13",
timing: "past",
},
],
estimateUnavailable: false,
from: "2021-09-10",
observedAt: quote.observedAt,
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
if (next && cursorError)
const isNext = url.searchParams.has("cursor");
if (isNext && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money(operationPayment),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "all",
hasNext: !isNext,
items: [
{
account: { id: "main", name: "Основной" },
category: isNext ? "trade" : "income",
description: "",
id: isNext ? "sold" : "dividend",
occurredAt: isNext
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
payment: money(operationPayment),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
const client = createTestQueryClient();
render(
@@ -208,71 +221,73 @@ function setup(
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/stocks/:stockId" element={<StockPage />} />
<Route element={<StockPage />} path="/stocks/:stockId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
isFailQuote = true;
},
recoverOperations: () => {
cursorError = undefined;
operationPayment = "201";
},
failQuote: () => {
failQuote = true;
},
requests,
};
}
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,
},
],
});
setup((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
metrics: [
{
currency: null,
key: "marketCapitalization",
period: null,
unit: "money",
value: 1_234_567.89,
},
{
currency: "RUB",
key: "revenueTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "USD",
key: "ebitdaTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "CAD",
key: "netIncomeTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.678,
},
{
currency: null,
key: "roe",
period: null,
unit: "percent",
value: 12.3456,
},
],
}
/* eslint-enable unicorn/no-null */));
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
@@ -288,12 +303,12 @@ describe("stock page", () => {
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some((url) => url.pathname === `/api/v1/stocks/${uid}/quote`),
requests.some(url => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
requests.some(url => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some((url) => url.pathname.includes("/SBERP/quote"))).toBe(
expect(requests.some(url => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
@@ -307,15 +322,15 @@ describe("stock page", () => {
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ path: "/stocks/SAME", ambiguousTicker: true });
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
(url) =>
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/position"),
url =>
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/position"),
),
).toBe(false);
});
@@ -346,15 +361,17 @@ describe("stock page", () => {
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ noPosition: true, failCompany: true });
setup({ failCompany: true, noPosition: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
});
@@ -362,7 +379,7 @@ describe("stock page", () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
@@ -370,8 +387,8 @@ describe("stock page", () => {
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
unavailable: [],
}),
);
}),
@@ -393,18 +410,18 @@ describe("stock page", () => {
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
expect(
requests.some(
(url) =>
url.searchParams.get("cursor") === "next" &&
url.searchParams.get("instrumentUid") === uid,
url =>
url.searchParams.get("cursor") === "next"
&& url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "trade" &&
!url.searchParams.has("cursor"),
url =>
url.searchParams.get("categories") === "trade"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
@@ -419,12 +436,12 @@ describe("stock page", () => {
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
requests.some(url => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it.each([
["INVALID_OPERATIONS_CURSOR", null, null],
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
["TBANK_UNAVAILABLE", "next", "observe"],
])(
"recovers history after %s with fresh data and the correct cursor",
@@ -447,7 +464,9 @@ describe("stock page", () => {
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
},
);
@@ -468,20 +487,22 @@ describe("stock page", () => {
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([
{
screen: "stock",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
});
+9 -8
View File
@@ -1,15 +1,17 @@
import type { FC } from "react";
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { StockPageView } from "./StockPage.view";
import { useStockPageVm } from "./StockPage.vm";
const StockPage: FC = () => {
const vm = useStockPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.canonicalTo) return <Navigate replace to={vm.canonicalTo} />;
if (vm.stockState.loading)
return (
<LoadingSkeleton label={t("stock.loading")}>
@@ -20,18 +22,17 @@ const StockPage: FC = () => {
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = vm.code === "STOCK_TICKER_AMBIGUOUS" ? "stock.ambiguousTicker" : "stock.unavailableText";
if (!vm.stock)
return (
<Stack role="alert">
<Title order={1}>{t("stock.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: vm.code === "STOCK_TICKER_AMBIGUOUS"
? "stock.ambiguousTicker"
: "stock.unavailableText",
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/stocks">
@@ -40,7 +41,7 @@ const StockPage: FC = () => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.stockState.retry}>
<Button onClick={vm.stockState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
+134 -119
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Alert,
Avatar,
@@ -11,8 +13,8 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
@@ -25,27 +27,29 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm";
import { StockDividends } from "./components/StockEvents.view";
import classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return null;
if (!stock) return;
return (
<Stack className={classes.page} gap="lg">
<Paper component="header" className={classes.section} withBorder>
<Group justify="space-between" align="start">
<Paper className={classes.section} component="header" withBorder>
<Group align="start" justify="space-between">
<div className={classes.identity}>
<Avatar
key={vm.logoSrc}
src={vm.logoSrc}
alt=""
size={56}
key={vm.logoSrc}
radius="md"
size={56}
src={vm.logoSrc}
>
{stock.name[0]}
</Avatar>
@@ -53,8 +57,11 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
<Title order={1} size="h2">
{stock.name}
</Title>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
<Text c="dimmed" size="sm">
{stock.ticker}
{" "}
·
{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
@@ -62,152 +69,155 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</div>
</div>
<Button
variant="subtle"
onClick={vm.refresh}
disabled={vm.refreshing}
onClick={vm.refresh}
variant="subtle"
>
{t("investment.refresh")}
</Button>
</Group>
{vm.company?.description ? (
<Text size="sm" mt="md" style={{ whiteSpace: "pre-line" }}>
{!!(vm.company?.description) && ((
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
) : null}
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
))}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
{vm.quoteState.loading ? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton mt="lg" h={55} w="50%" />
</LoadingSkeleton>
) : (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price ?? null)}
</Text>
{vm.quote?.change ? (
<Text
fw={600}
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent !== null
? ` (${formatDecimal(vm.quote.changePercent)} %)`
: ""}
</Text>
) : null}
{vm.quote?.previousCloseDate ? (
<Text size="xs" c="dimmed">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{vm.quote?.priceAt ? (
<Text size="xs" c="dimmed">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text c="dimmed" size="xs" mt="xs">
{t("stock.partialQuote")}
</Text>
) : null}
{vm.quoteState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
</div>
)}
{!!(vm.quote?.change) && ((
<Text
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
fw={600}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
))}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!(vm.quote?.priceAt) && ((
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
))}
</Group>
{!!(vm.quote?.unavailable.length) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
))}
{!!(vm.quoteState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
</div>
)}
<StockSection
title={t("stock.company")}
state={vm.companyState}
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{stock.sector ? (
{!!(stock.sector) && ((
<Text size="sm">
{t("stock.sector")}:{" "}
{t("stock.sector")}
:
{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
) : null}
{stock.lot !== null ? (
))}
{stock.lot !== null && ((
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
) : null}
))}
</Group>
{vm.company ? (
{!!(vm.company) && ((
<Stack gap="lg">
{vm.company.unavailable.length ? (
<Alert color="orange" role="status" aria-live="polite">
{vm.company.unavailable.length > 0 && ((
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
variant="default"
onClick={vm.companyState.retry}
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{vm.company.metrics.length ? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map((metric) => (
<div key={metric.key}>
<Text size="xs" c="dimmed">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
))}
{vm.company.metrics.length > 0
? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? metric.currency === "RUB"
? "₽"
: (metric.currency ?? t("stock.currencyUnknown"))
: metric.unit === "percent"
? "%"
: "×"}
</Text>
}).format(metric.value)}
{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
))}
</div>
) : (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text size="xs" c="dimmed">
)
: (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
) : null}
))}
</StockSection>
</Paper>
<StockChart vm={vm} />
@@ -218,4 +228,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
);
};
function metricUnit(metric: NonNullable<StockPageVm["company"]>["metrics"][number], unknownCurrency: string) {
if (metric.unit === "money") return metric.currency === "RUB" ? "₽" : (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockPageView };
+72 -69
View File
@@ -1,5 +1,7 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
@@ -22,118 +24,119 @@ const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number];
const categories: Record<StockEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type StockPageVm = ReturnType<typeof useStockPageVm>;
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.data?.instrumentUid ?? "";
const [params, setParams] = useSearchParams();
const period =
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const [parameters, setParameters] = useSearchParams();
const period
= periods.find(value => value === parameters.get("period")) ?? "month";
const filter
= filters.find(value => value === parameters.get("events")) ?? "all";
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const history = useInstrumentOperations(uid, filter, categories[filter]);
const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
const { operations } = history;
const sync = useScreenSync(
{
screen: "stock",
instrumentUid: uid,
candlePeriod: period,
accountId: "all",
categories: categories[filter],
candlePeriod: period,
categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
[stock, quote, candles, position, company, dividends, operations],
);
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParams(next, { replace: true });
setParameters(next, { replace: true });
};
const chartData =
candles.data?.candles.map((candle) => ({
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = position.data?.averagePurchasePrice;
const averageLine =
average && average.currency === candles.data?.currency
const averageLine
= average && average.currency === candles.data?.currency
? Number(average.value)
: null;
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
const companyData = company.data;
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote, sync.retry),
candles: candles.data,
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.retry),
code: apiErrorCode(stock.error),
company: companyData,
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
pastDividends:
dividends.data?.dividends
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? stock.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? stock.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
})) ?? [],
filter,
hasIncompleteCandle:
candles.data?.candles.some(candle => !candle.isComplete) ?? false,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName),
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? stock.data?.ticker ?? null,
logoName: item.logoName ?? stock.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
pastDividends:
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
period,
position: position.data,
positionState: section(position, sync.retry),
priceRows: chartData.toReversed(),
quote: quote.data,
quoteState: section(quote, sync.retry),
refresh: sync.refresh,
refreshError: sync.transportError || sync.sourceError,
refreshing: sync.refreshing,
setFilter: (value: string) => setParameter("events", value),
setPeriod: (value: string) => setParameter("period", value),
stock: stock.data,
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
upcoming:
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
};
}
type StockPageVm = ReturnType<typeof useStockPageVm>;
export { type StockPageVm, useStockPageVm };
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Accordion,
Alert,
@@ -7,162 +9,171 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { InstrumentSection as StockSection } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import classes from "../StockPage.module.css";
type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection title={t("stock.upcoming")} state={vm.dividendsState}>
{vm.dividends ? (
<StockSection state={vm.dividendsState} title={t("stock.upcoming")}>
{!!(vm.dividends) && ((
<Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
<Alert color="orange" role="status" aria-live="polite">
{!!(vm.dividends.estimateUnavailable || vm.dividends.positionIsStale) && ((
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((event) => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group justify="space-between" align="start">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text size="xl" fw={650} mt="xs">
{formatMoney(event.perShare)}{" "}
<Text component="span" size="sm" c="dimmed">
{t("stock.perShare")}
</Text>
</Text>
))}
{vm.upcoming.length > 0
? (
vm.upcoming.map(event => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group align="start" justify="space-between">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text fw={650} mt="xs" size="xl">
{formatMoney(event.perShare)}
{" "}
<Text c="dimmed" component="span" size="sm">
{t("stock.perShare")}
</Text>
</Text>
</div>
{!!(event.estimatedAmount) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
))}
</Group>
<Group gap="xl" mt="md">
{!!(event.lastBuyDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
))}
{!!(event.recordDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
))}
{!!(event.paymentDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
))}
</Group>
</div>
{event.estimatedAmount ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
) : null}
</Group>
<Group gap="xl" mt="md">
{event.lastBuyDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
) : null}
{event.recordDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
) : null}
{event.paymentDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
) : null}
</Group>
</div>
))
) : (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text size="xs" c="dimmed">
))
)
: (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text c="dimmed" size="xs">
{t("stock.dividendNote")}
</Text>
{vm.dividends.positionAt ? (
<Text size="xs" c="dimmed">
{!!(vm.dividends.positionAt) && ((
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
))}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>
<Text fw={600}>{t("stock.dividendHistory")}</Text>
</Accordion.Control>
<Accordion.Panel>
<Text size="xs" c="dimmed" my="sm">
<Text c="dimmed" my="sm" size="xs">
{t("stock.dividendWindow", {
from: formatDate(vm.dividends.from),
to: formatDate(vm.dividends.to),
})}
</Text>
{vm.pastDividends.length ? (
<Stack gap="sm">
{vm.pastDividends.map((event) => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
withBorder
p="md"
radius="md"
>
<Group justify="space-between" align="start">
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{event.paymentDate ? (
<Text size="xs" c="dimmed" mt="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
) : null}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text size="xs" c="dimmed">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
{vm.pastDividends.length > 0
? (
<Stack gap="sm">
{vm.pastDividends.map(event => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
p="md"
radius="md"
withBorder
>
<Group align="start" justify="space-between">
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{!!(event.paymentDate) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.payment")}
:
{" "}
{formatDate(event.paymentDate)}
</Text>
))}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text c="dimmed" size="xs">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
)
: (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
))}
</StockSection>
);
};