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
+178 -157
View File
@@ -1,13 +1,15 @@
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 { BondPage } from "./BondPage";
const LocationProbe = () => {
@@ -21,74 +23,78 @@ const LocationProbe = () => {
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const bond = {
instrumentUid: uid,
name: "Тестовая облигация",
ticker: "TEST",
nominal: money("500"),
initialNominal: money("1000"),
aci: money("12"),
maturityDate: "2030-05-15",
callDate: null,
couponQuantityPerYear: 4,
floatingCoupon: true,
amortization: true,
perpetual: false,
subordinated: false,
isin: "TESTISIN",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const money = (value: string) => ({ currency: "RUB", value });
const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
aci: money("12"),
amortization: true,
callDate: null,
couponQuantityPerYear: 4,
currency: "RUB",
floatingCoupon: true,
historyFrom: null,
initialNominal: money("1000"),
instrumentUid: uid,
isin: "TESTISIN",
logoName: null,
lot: 10,
maturityDate: "2030-05-15",
name: "Тестовая облигация",
nominal: money("500"),
perpetual: false,
sector: "financial",
subordinated: false,
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 = {
pricePercent: "98.125",
cleanPrice: money("490.625"),
aci: money("12"),
dirtyPrice: money("502.625"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: null,
changePercentagePoints: "-0.875",
previousCloseDate: "2026-09-09",
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
aci: money("12"),
changePercentagePoints: "-0.875",
cleanPrice: money("490.625"),
dirtyPrice: money("502.625"),
observedAt: "2026-09-10T10:00:00Z",
previousCloseDate: "2026-09-09",
priceAt: "2026-09-10T10:00:00Z",
pricePercent: "98.125",
tradingStatus: null,
unavailable: [],
}
/* eslint-enable unicorn/no-null */);
function setup(
options: {
noPosition?: boolean;
failCoupons?: boolean;
ratingState?: "active" | "withdrawn" | "missing" | "unavailable";
failBond?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
failBond?: boolean;
failCoupons?: boolean;
noPosition?: boolean;
path?: string;
ratingState?: "active" | "missing" | "unavailable" | "withdrawn";
slug?: string;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...bond, slug: options.slug ?? uid };
let failQuote = false;
let isFailQuote = false;
server.use(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
@@ -101,71 +107,78 @@ function setup(
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
http.get("*/api/v1/bonds/:uid/metrics", ({ request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
return HttpResponse.json(
resource({
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
coupon: money("32.88"),
couponDate: "2026-10-27",
couponPeriodDays: 90,
couponType: "COUPON_TYPE_FLOATING",
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
options.ratingState === "missing"
|| options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
date: "2026-04-17",
observedAt: quote.observedAt,
sourceUrl: "https://ratings.ru/ratings/issues/123/",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
value:
options.ratingState === "withdrawn" ? null : "AAA.ru",
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
pricePercent: "53.384",
tradeAt: quote.priceAt,
value: "15.6262",
},
observedAt: quote.observedAt,
unavailable:
options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
}),
);
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/bonds/: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: [
{
time: "2026-09-09T00:00:00Z",
close: "99.25",
isComplete: true,
},
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
candles: [
{
close: "99.25",
isComplete: true,
time: "2026-09-09T00:00:00Z",
},
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
if (options.failCoupons)
return HttpResponse.json(
@@ -173,71 +186,75 @@ function setup(
{ status: 503 },
);
return HttpResponse.json(
resource({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: 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. */
{
coupons: [
{
couponType: "COUPON_TYPE_FLOATING",
estimatedAmount: options.noPosition ? null : money("328.8"),
number: "1",
paymentDate: "2026-10-27",
perBond: money("32.88"),
periodDays: 90,
recordDate: "2026-10-13",
timing: "upcoming",
},
{
couponType: null,
estimatedAmount: null,
number: "2",
paymentDate: null,
perBond: money("20"),
periodDays: null,
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");
const isNext = url.searchParams.has("cursor");
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_COUPON",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
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("200"),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
},
],
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(
@@ -246,17 +263,17 @@ function setup(
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/bonds/:bondId" element={<BondPage />} />
<Route element={<BondPage />} path="/bonds/:bondId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
isFailQuote = true;
},
requests,
};
}
describe("bond page", () => {
@@ -280,11 +297,11 @@ describe("bond page", () => {
expect(await screen.findByText("328,8 ₽")).toBeVisible();
await waitFor(() =>
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 === `/api/v1/bonds/${uid}/quote`),
requests.some(url => url.pathname === `/api/v1/bonds/${uid}/quote`),
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible());
@@ -317,11 +334,13 @@ describe("bond page", () => {
);
});
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true });
setup({ failCoupons: true, noPosition: true });
await screen.findByText("98,125 %");
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(
@@ -346,32 +365,34 @@ describe("bond page", () => {
await screen.findByText("98,125 %");
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("98,125 %")).toBeVisible();
expect(scopes).toEqual([
{
screen: "bond",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "bond",
},
]);
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" });
expect(
await screen.findByText(/Тикер соответствует нескольким выпускам/),
).toBeVisible();
expect(
screen.getByRole("link", { name: "К поиску облигаций" }),
).toHaveAttribute("href", "/bonds");
expect(requests.some((url) => url.pathname.endsWith("/position"))).toBe(
expect(requests.some(url => url.pathname.endsWith("/position"))).toBe(
false,
);
});
@@ -384,16 +405,16 @@ describe("bond page", () => {
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "income" &&
!url.searchParams.has("cursor"),
url =>
url.searchParams.get("categories") === "income"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
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),
);
});
+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 { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
const BondPage: FC = () => {
const vm = useBondPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.canonicalTo) return <Navigate replace to={vm.canonicalTo} />;
if (vm.bondState.loading)
return (
<LoadingSkeleton label={t("bond.loading")}>
@@ -20,18 +22,17 @@ const BondPage: FC = () => {
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = vm.code === "BOND_TICKER_AMBIGUOUS" ? "bond.ambiguousTicker" : "bond.unavailableText";
if (!vm.bond)
return (
<Stack role="alert">
<Title order={1}>{t("bond.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
vm.code === "TBANK_NOT_CONNECTED"
|| vm.code === "INVALID_TBANK_TOKEN"
? "bond.connect"
: vm.code === "BOND_TICKER_AMBIGUOUS"
? "bond.ambiguousTicker"
: "bond.unavailableText",
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/bonds">
@@ -40,7 +41,7 @@ const BondPage: FC = () => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.bondState.retry}>
<Button onClick={vm.bondState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
+322 -290
View File
@@ -1,3 +1,5 @@
import type { FC } from "react";
import {
Accordion,
Alert,
@@ -13,8 +15,8 @@ import {
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
InstrumentChart,
InstrumentEvents,
@@ -27,133 +29,143 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
import classes from "./BondPage.module.css";
type BondPageViewProps = { vm: BondPageVm };
const BondPageView: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const bond = vm.bond;
if (!bond) return null;
if (!bond) return;
const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown");
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 src={vm.logoSrc} alt="" size={56} radius="md">
<Avatar alt="" radius="md" size={56} src={vm.logoSrc}>
{bond.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{bond.name}
</Title>
<Text size="sm" c="dimmed">
{bond.ticker} · {bond.isin}
<Text c="dimmed" size="sm">
{bond.ticker}
{" "}
·
{" "}
{bond.isin}
</Text>
</div>
</div>
<Button
variant="subtle"
onClick={vm.refresh}
disabled={vm.refreshing}
onClick={vm.refresh}
variant="subtle"
>
{t("investment.refresh")}
</Button>
</Group>
<Group mt="md" gap="xs">
{bond.floatingCoupon ? (
<Group gap="xs" mt="md">
{!!(bond.floatingCoupon) && ((
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
) : null}
{bond.amortization ? (
))}
{!!(bond.amortization) && ((
<Badge variant="light">{t("bond.amortization")}</Badge>
) : null}
{bond.perpetual ? (
))}
{!!(bond.perpetual) && ((
<Badge variant="light">{t("bond.perpetual")}</Badge>
) : null}
{bond.subordinated ? (
<Badge variant="light" color="orange">
))}
{!!(bond.subordinated) && ((
<Badge color="orange" variant="light">
{t("bond.subordinated")}
</Badge>
) : null}
))}
</Group>
{vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
<Alert role="status" aria-live="polite" color="orange" mt="md">
{!!(vm.refreshError || vm.bondState.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>
) : (
<Stack gap="xs" mt="lg" aria-live="polite">
<Text size="sm" c="dimmed">
{t("bond.percentOfNominal")}
</Text>
<Text className={classes.price}>
{vm.quote?.pricePercent != null
? `${formatDecimal(vm.quote.pricePercent)} %`
: t("bond.unknown")}
</Text>
{vm.quote?.changePercentagePoints != null ? (
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
{t("bond.percentagePoints")}
</Text>
) : null}
<Text size="xs" c="dimmed">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice ?? null)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci ?? null)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice ?? null)}
/>
</Group>
<Text size="xs" c="dimmed">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{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>
)
: (
<Stack aria-live="polite" gap="xs" mt="lg">
<Text c="dimmed" size="sm">
{t("bond.percentOfNominal")}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text size="xs" c="dimmed">
{t("stock.partialQuote")}
</Text>
) : null}
</Stack>
)}
{vm.quoteState.refreshing || vm.bondState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
<Text className={classes.price}>
{vm.quote?.pricePercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`}
</Text>
{vm.quote?.changePercentagePoints != undefined && ((
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}
{" "}
{t("bond.percentagePoints")}
</Text>
))}
<Text c="dimmed" size="xs">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice)}
/>
</Group>
<Text c="dimmed" size="xs">
{t("bond.indicativePrice")}
</Text>
<Group>
<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" size="xs">
{t("stock.partialQuote")}
</Text>
))}
</Stack>
)}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
) : null}
))}
<div className={classes.embeddedSection}>
<Title order={2} size="h3" mb="md">
<Title mb="md" order={2} size="h3">
{t("bond.terms")}
</Title>
<div className={classes.companyMetrics}>
@@ -170,9 +182,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
value={
bond.perpetual
? t("bond.perpetual")
: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown")
: maturity
}
/>
<Metric
@@ -184,207 +194,226 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<Metric
label={t("bond.couponFrequency")}
value={
bond.couponQuantityPerYear != null
? String(bond.couponQuantityPerYear)
: t("bond.unknown")
bond.couponQuantityPerYear == undefined
? t("bond.unknown")
: String(bond.couponQuantityPerYear)
}
/>
<Metric
label={t("bond.lot")}
value={bond.lot != null ? String(bond.lot) : t("bond.unknown")}
value={bond.lot == undefined ? t("bond.unknown") : String(bond.lot)}
/>
</div>
</div>
</Paper>
<InstrumentSection title={t("bond.metrics")} state={vm.metricsState}>
{vm.metrics ? (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
vm.metrics.creditRating
? vm.metrics.creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (vm.metrics.creditRating.value ?? t("bond.unknown"))
: vm.metrics.unavailable.includes("rating")
? t("bond.ratingUnavailable")
: t("bond.ratingMissing")
}
/>
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent !== null
? `${formatDecimal(vm.metrics.couponYieldPercent)} %`
: t("bond.unknown")
}
/>
</div>
{vm.metrics.creditRating ? (
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text size="xs" c="dimmed">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
})}
</Text>
</Group>
) : null}
<Text size="xs" c="dimmed">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield ? (
<Text size="xs" c="dimmed">
{t("bond.yieldSource", {
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("bond.yieldUnavailable")}
</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponYieldNote")}
</Text>
{vm.metrics.couponYieldPriceAt ? (
<Text size="xs" c="dimmed">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
})}
</Text>
) : null}
{vm.metrics.unavailable.length ? (
<Text size="xs" c="dimmed">
{t("bond.metricsPartial")}
</Text>
) : null}
</Stack>
) : null}
</InstrumentSection>
<InstrumentChart vm={vm} priceUnit="percent" />
<BondMetrics vm={vm} />
<InstrumentChart priceUnit="percent" vm={vm} />
<InstrumentPosition
vm={vm}
returnNote={t("bond.returnNote")}
quantityLabel={t("bond.quantity")}
/>
<InstrumentSection
title={t("bond.upcomingCoupons")}
state={vm.couponsState}
>
{vm.coupons ? (
<Stack gap="md">
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
<Alert color="orange" role="status" aria-live="polite">
{t("stock.estimateUnavailable")}
</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text size="xs" c="dimmed">
{t("bond.couponNote")}
</Text>
{vm.coupons.positionAt ? (
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length ? (
vm.pastCoupons.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
</InstrumentSection>
<InstrumentEvents
returnNote={t("bond.returnNote")}
vm={vm}
incomeLabel={t("bond.coupons")}
incomeFilter="coupons"
/>
<BondCoupons vm={vm} />
<InstrumentEvents
historyNote={t("bond.historyNote")}
incomeFilter="coupons"
incomeLabel={t("bond.coupons")}
vm={vm}
/>
</Stack>
);
};
const BondMetrics: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const creditRating = vm.metrics?.creditRating;
let rating = t(vm.metrics?.unavailable.includes("rating") ? "bond.ratingUnavailable" : "bond.ratingMissing");
if (creditRating) rating = creditRating.status === "withdrawn" ? t("bond.ratingWithdrawn") : (creditRating.value ?? t("bond.unknown"));
return (
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
{!!(vm.metrics) && ((
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
rating
}
/>
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent === null
? t("bond.unknown")
: `${formatDecimal(vm.metrics.couponYieldPercent)} %`
}
/>
</div>
{!!(vm.metrics.creditRating) && ((
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
rel="noopener noreferrer"
size="sm"
target="_blank"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text c="dimmed" size="xs">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
})}
</Text>
</Group>
))}
<Text c="dimmed" size="xs">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield
? (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
)}
<Text c="dimmed" size="xs">
{t("bond.couponYieldNote")}
</Text>
{!!(vm.metrics.couponYieldPriceAt) && ((
<Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
})}
</Text>
))}
{vm.metrics.unavailable.length > 0 && ((
<Text c="dimmed" size="xs">
{t("bond.metricsPartial")}
</Text>
))}
</Stack>
))}
</InstrumentSection>
);
};
const BondCoupons: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<InstrumentSection
state={vm.couponsState}
title={t("bond.upcomingCoupons")}
>
{!!(vm.coupons) && ((
<Stack gap="md">
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ((
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
))}
{vm.upcoming.length > 0
? (
vm.upcoming.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text c="dimmed" size="xs">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNote")}
</Text>
{!!(vm.coupons.positionAt) && ((
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
))}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length > 0
? (
vm.pastCoupons.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
))}
</InstrumentSection>
);
};
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = (props) => (
const Metric: FC<MetricProps> = props => (
<div>
<Text size="xs" c="dimmed">
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
@@ -399,44 +428,47 @@ const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="start">
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text size="xs" c="dimmed">
<Text c="dimmed" size="xs">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{coupon.estimatedAmount ? (
{!!(coupon.estimatedAmount) && ((
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
) : null}
))}
</Group>
<Group mt="sm" gap="md">
<Badge variant="light" color="gray">
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != null ? (
<Text size="xs" c="dimmed">
{coupon.periodDays != undefined && ((
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
) : null}
{coupon.recordDate ? (
<Text size="xs" c="dimmed">
{t("stock.record")}: {formatDate(coupon.recordDate)}
))}
{!!(coupon.recordDate) && ((
<Text c="dimmed" size="xs">
{t("stock.record")}
:
{" "}
{formatDate(coupon.recordDate)}
</Text>
) : null}
))}
</Group>
</Paper>
);
+70 -67
View File
@@ -1,4 +1,7 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type BondPeriod,
useBond,
@@ -8,7 +11,6 @@ import {
useBondPosition,
useBondQuote,
} from "@/entities/bond";
import type { OperationCategory } from "@/entities/broker-operation";
import {
section,
useInstrumentOperations,
@@ -22,111 +24,112 @@ const filters = ["all", "trades", "coupons", "other"] as const;
type BondEventFilter = (typeof filters)[number];
const categories: Record<BondEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
coupons: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type BondPageVm = ReturnType<typeof useBondPageVm>;
function useBondPageVm() {
const { bondId = "" } = useParams();
const bond = useBond(bondId);
const uid = bond.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 = useBondQuote(uid);
const metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(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: "bond",
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: "bond",
},
[bond, quote, candles, position, metrics, coupons, 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 search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
refresh: sync.refresh,
refreshing: sync.refreshing,
refreshError: sync.transportError || sync.sourceError,
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
averageLine: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
chartData,
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? bond.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? bond.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(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.retry),
quote: quote.data,
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
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.retry),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [],
pastCoupons:
coupons.data?.coupons
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [],
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 ?? bond.data?.ticker ?? null,
logoName: item.logoName ?? bond.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
pastCoupons:
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [],
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),
upcoming:
coupons.data?.coupons
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [],
};
}
type BondPageVm = ReturnType<typeof useBondPageVm>;
export { type BondPageVm, useBondPageVm };