refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -16,9 +16,11 @@ import { StockPage } from "./StockPage";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<output data-testid="location">
|
||||
{location.pathname}
|
||||
|
||||
{location.search}
|
||||
</output>
|
||||
);
|
||||
@@ -26,8 +28,11 @@ const LocationProbe = () => {
|
||||
|
||||
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const stock
|
||||
= {
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
instrumentUid: uid,
|
||||
@@ -37,8 +42,9 @@ const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the produc
|
||||
sector: "financial",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
ticker: "TEST",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const position = {
|
||||
absoluteReturn: money("1000"),
|
||||
accounts: [
|
||||
@@ -59,6 +65,7 @@ const position = {
|
||||
quantity: "10",
|
||||
returnPercent: "50",
|
||||
};
|
||||
|
||||
const quote = {
|
||||
change: money("10"),
|
||||
changePercent: "3.448276",
|
||||
@@ -70,6 +77,7 @@ const quote = {
|
||||
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
|
||||
unavailable: [],
|
||||
};
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
ambiguousTicker?: boolean;
|
||||
@@ -95,6 +103,7 @@ function setup(
|
||||
),
|
||||
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
|
||||
return options.ambiguousTicker
|
||||
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||
: HttpResponse.json(resource(resolved));
|
||||
@@ -115,20 +124,23 @@ function setup(
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
description: "Описание компании",
|
||||
metrics: options.metrics ?? [
|
||||
@@ -142,11 +154,14 @@ function setup(
|
||||
],
|
||||
observedAt: quote.observedAt,
|
||||
unavailable: [],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
dividends: [
|
||||
{
|
||||
@@ -172,8 +187,9 @@ function setup(
|
||||
positionAt: null,
|
||||
positionIsStale: false,
|
||||
to: "2027-09-10",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
@@ -182,8 +198,10 @@ function setup(
|
||||
const isNext = url.searchParams.has("cursor");
|
||||
if (isNext && cursorError)
|
||||
return HttpResponse.json({ code: cursorError }, { status: 400 });
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
accountId: "all",
|
||||
hasNext: !isNext,
|
||||
@@ -199,13 +217,16 @@ function setup(
|
||||
payment: money(operationPayment),
|
||||
price: isNext ? money("100") : null,
|
||||
quantity: isNext ? "2" : null,
|
||||
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
|
||||
type: isNext
|
||||
? "OPERATION_TYPE_SELL"
|
||||
: "OPERATION_TYPE_DIVIDEND",
|
||||
},
|
||||
],
|
||||
nextCursor: isNext ? null : "next",
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -220,6 +241,7 @@ function setup(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
|
||||
<LocationProbe />
|
||||
|
||||
<Routes>
|
||||
<Route element={<StockPage />} path="/stocks/:stockId" />
|
||||
</Routes>
|
||||
@@ -227,6 +249,7 @@ function setup(
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
failQuote: () => {
|
||||
isFailQuote = true;
|
||||
@@ -238,9 +261,11 @@ function setup(
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stock page", () => {
|
||||
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
|
||||
setup((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
setup(
|
||||
{
|
||||
metrics: [
|
||||
{
|
||||
@@ -286,8 +311,9 @@ describe("stock page", () => {
|
||||
value: 12.3456,
|
||||
},
|
||||
],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */));
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
);
|
||||
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
|
||||
for (const value of [
|
||||
"1,23 млн ₽",
|
||||
@@ -370,9 +396,7 @@ describe("stock page", () => {
|
||||
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
|
||||
expect(
|
||||
// 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\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
it("recovers an initially missing company section without reloading successful sections", async () => {
|
||||
@@ -383,6 +407,7 @@ describe("stock page", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
|
||||
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
description: "Восстановленное описание",
|
||||
@@ -441,7 +466,12 @@ describe("stock page", () => {
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
|
||||
[
|
||||
"INVALID_OPERATIONS_CURSOR",
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
null,
|
||||
null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
|
||||
],
|
||||
["TBANK_UNAVAILABLE", "next", "observe"],
|
||||
])(
|
||||
"recovers history after %s with fresh data and the correct cursor",
|
||||
@@ -475,24 +505,25 @@ describe("stock page", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const controls = setup({
|
||||
path: `/stocks/${uid}?period=year&events=trades`,
|
||||
});
|
||||
|
||||
await screen.findByText("300 ₽");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||
const message = await screen.findByText(/последние полученные/);
|
||||
expect(
|
||||
// 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\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
|
||||
@@ -19,16 +19,26 @@ const StockPage: FC = () => {
|
||||
<LoadingSkeleton label={t("stock.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={110} />
|
||||
|
||||
<Skeleton h={350} />
|
||||
|
||||
<Skeleton h={170} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.StockTickerAmbiguous) ? "stock.ambiguousTicker" : "stock.unavailableText";
|
||||
|
||||
const unavailableMessage = isApiErrorCode(
|
||||
vm.code,
|
||||
ApiErrorCode.StockTickerAmbiguous,
|
||||
)
|
||||
? "stock.ambiguousTicker"
|
||||
: "stock.unavailableText";
|
||||
|
||||
if (!vm.stock)
|
||||
return (
|
||||
<Stack role="alert">
|
||||
<Title order={1}>{t("stock.unavailable")}</Title>
|
||||
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
@@ -37,17 +47,21 @@ const StockPage: FC = () => {
|
||||
: unavailableMessage,
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/stocks">
|
||||
{t("stock.backToSearch")}
|
||||
</Anchor>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
|
||||
<Button onClick={vm.stockState.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return <StockPageView vm={vm} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,11 +34,124 @@ import { StockDividends } from "./components/StockEvents.view";
|
||||
import classes from "./StockPage.module.css";
|
||||
|
||||
type StockPageViewProps = { vm: StockPageVm };
|
||||
|
||||
const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const stock = vm.stock;
|
||||
if (!stock) return;
|
||||
|
||||
const appearanceClass = vm.quote?.change?.value.startsWith("-")
|
||||
? classes.negative
|
||||
: classes.positive;
|
||||
|
||||
let quoteContent;
|
||||
|
||||
if (vm.quoteState.loading) {
|
||||
quoteContent = (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
quoteContent = (
|
||||
<div aria-live="polite">
|
||||
<Text className={classes.price} mt="lg">
|
||||
{formatMoney(vm.quote?.price)}
|
||||
</Text>
|
||||
|
||||
{!!vm.quote?.change && (
|
||||
<Text className={appearanceClass} 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>
|
||||
);
|
||||
}
|
||||
|
||||
let companyMetricsContent;
|
||||
|
||||
if (vm.company) {
|
||||
if (vm.company.metrics.length > 0) {
|
||||
companyMetricsContent = (
|
||||
<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)}{" "}
|
||||
{metricUnit(metric, t("stock.currencyUnknown"))}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
companyMetricsContent = (
|
||||
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Paper className={classes.section} component="header" withBorder>
|
||||
@@ -53,21 +166,21 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
>
|
||||
{stock.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
{stock.name}
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing}
|
||||
onClick={vm.refresh}
|
||||
@@ -76,108 +189,48 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
{!!(vm.company?.description) && ((
|
||||
|
||||
{!!vm.company?.description && (
|
||||
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
|
||||
{vm.company.description}
|
||||
</Text>
|
||||
))}
|
||||
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
|
||||
)}
|
||||
|
||||
{!!(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>
|
||||
))}
|
||||
{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>
|
||||
{!!(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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{quoteContent}
|
||||
|
||||
<StockSection
|
||||
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>
|
||||
))}
|
||||
{stock.lot !== null && ((
|
||||
)}
|
||||
|
||||
{stock.lot !== null && (
|
||||
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
{!!(vm.company) && ((
|
||||
|
||||
{!!vm.company && (
|
||||
<Stack gap="lg">
|
||||
{vm.company.unavailable.length > 0 && ((
|
||||
{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"
|
||||
@@ -187,49 +240,38 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
))}
|
||||
{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)}
|
||||
{" "}
|
||||
{metricUnit(metric, t("stock.currencyUnknown"))}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
|
||||
)}
|
||||
)}
|
||||
|
||||
{companyMetricsContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.periodNote")}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
)}
|
||||
</StockSection>
|
||||
</Paper>
|
||||
|
||||
<StockChart vm={vm} />
|
||||
|
||||
<StockPosition vm={vm} />
|
||||
|
||||
<StockDividends vm={vm} />
|
||||
|
||||
<StockEvents vm={vm} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function metricUnit(metric: NonNullable<StockPageVm["company"]>["metrics"][number], unknownCurrency: string) {
|
||||
if (metric.unit === "money") return metric.currency === "RUB" ? "₽" : (metric.currency ?? unknownCurrency);
|
||||
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" ? "%" : "×";
|
||||
}
|
||||
|
||||
|
||||
@@ -21,68 +21,93 @@ import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
|
||||
const filters = ["all", "trades", "dividends", "other"] as const;
|
||||
|
||||
type StockEventFilter = (typeof filters)[number];
|
||||
|
||||
const categories: Record<StockEventFilter, OperationCategory[]> = {
|
||||
all: [],
|
||||
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 [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, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
|
||||
|
||||
const history = useInstrumentOperations(
|
||||
uid,
|
||||
filter,
|
||||
// eslint-disable-next-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(
|
||||
{
|
||||
accountId: "all",
|
||||
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],
|
||||
categories:
|
||||
// eslint-disable-next-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 setParameter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(parameters);
|
||||
next.set(key, value);
|
||||
setParameters(next, { replace: true });
|
||||
};
|
||||
|
||||
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
|
||||
? Number(average.value)
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
|
||||
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
|
||||
const companyData = company.data;
|
||||
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
|
||||
|
||||
return {
|
||||
averageLine:
|
||||
averageLine !== null && Number.isFinite(averageLine) ? averageLine : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
averageLine !== null && Number.isFinite(averageLine)
|
||||
? averageLine
|
||||
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
candles: candles.data,
|
||||
candlesState: section(candles, sync.retry),
|
||||
canonicalTo:
|
||||
stock.data?.slug && stock.data.slug !== stockId
|
||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
chartData,
|
||||
code: apiErrorCode(stock.error),
|
||||
company: companyData,
|
||||
@@ -92,8 +117,16 @@ function useStockPageVm() {
|
||||
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,
|
||||
logoName:
|
||||
item.logoName
|
||||
?? stock.data?.logoName
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
?? null,
|
||||
ticker:
|
||||
item.ticker
|
||||
?? stock.data?.ticker
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
?? null,
|
||||
})) ?? [],
|
||||
filter,
|
||||
hasIncompleteCandle:
|
||||
|
||||
@@ -19,100 +19,186 @@ 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();
|
||||
|
||||
let upcomingContent;
|
||||
|
||||
if (vm.dividends) {
|
||||
if (vm.upcoming.length > 0) {
|
||||
upcomingContent = (
|
||||
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>
|
||||
))
|
||||
);
|
||||
}
|
||||
else {
|
||||
upcomingContent = (
|
||||
<Text c="dimmed">
|
||||
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pastDividendsContent;
|
||||
|
||||
if (vm.pastDividends.length > 0) {
|
||||
pastDividendsContent = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
else {
|
||||
pastDividendsContent = (
|
||||
<Text c="dimmed">{t("stock.noDividends")}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StockSection state={vm.dividendsState} title={t("stock.upcoming")}>
|
||||
{!!(vm.dividends) && ((
|
||||
{!!vm.dividends && (
|
||||
<Stack gap="md">
|
||||
{!!(vm.dividends.estimateUnavailable || vm.dividends.positionIsStale) && ((
|
||||
{!!(
|
||||
vm.dividends.estimateUnavailable || vm.dividends.positionIsStale
|
||||
) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
))}
|
||||
{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>
|
||||
))
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed">
|
||||
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
|
||||
</Text>
|
||||
)}
|
||||
)}
|
||||
|
||||
{upcomingContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.dividendNote")}
|
||||
</Text>
|
||||
{!!(vm.dividends.positionAt) && ((
|
||||
|
||||
{!!vm.dividends.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.dividends.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Accordion radius="md" variant="contained">
|
||||
<Accordion.Item value="history">
|
||||
<Accordion.Control>
|
||||
<Text fw={600}>{t("stock.dividendHistory")}</Text>
|
||||
</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Text c="dimmed" my="sm" size="xs">
|
||||
{t("stock.dividendWindow", {
|
||||
@@ -120,60 +206,13 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
to: formatDate(vm.dividends.to),
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{pastDividendsContent}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
))}
|
||||
)}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user