refactor: encapsulate instrument chart data
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m45s

This commit is contained in:
2026-09-28 20:46:06 +03:00
parent fb3a5a6eaa
commit 64fe902192
7 changed files with 50 additions and 65 deletions
@@ -7,17 +7,13 @@ type Candles = NonNullable<
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
type ChartRow = Candles["candles"][number] & { date: string; price: number }; type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
type InstrumentChartVm = { type InstrumentChartVm = {
averageLine: null | number;
candles?: Candles; candles?: Candles;
candlesState: SectionState; candlesState: SectionState;
chartData: ChartRow[];
hasIncompleteCandle: boolean;
period: string; period: string;
position?: Position; position?: Position;
priceRows: ChartRow[];
setPeriod: (value: string) => void; setPeriod: (value: string) => void;
}; };
@@ -41,4 +37,4 @@ type Position = NonNullable<
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
>; >;
export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm };
@@ -10,20 +10,19 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { formatMoney } from "@/shared/lib"; import { formatChartValue, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
import classes from "../InstrumentDetails.module.css"; import classes from "../InstrumentDetails.module.css";
import { InstrumentSection } from "../InstrumentSection.view"; import { InstrumentSection } from "../InstrumentSection.view";
import { useInstrumentChartVm } from "./InstrumentChart.vm"; import { useInstrumentChartVm } from "./InstrumentChart.vm";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
const InstrumentChart: FC<InstrumentChartProps> = (props) => { const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const { const {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle,
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,
@@ -34,14 +33,14 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
let chartContent; let chartContent;
if (vm.candles) { if (vm.candles) {
if (vm.chartData.length > 0) { if (chartData.length > 0) {
chartContent = ( chartContent = (
<> <>
<AreaChart <AreaChart
areaProps={{ isAnimationActive: false }} areaProps={{ isAnimationActive: false }}
aria-label={chartLabel} aria-label={chartLabel}
curveType="linear" curveType="linear"
data={vm.chartData} data={chartData}
dataKey="date" dataKey="date"
gridAxis="y" gridAxis="y"
h={310} h={310}
@@ -52,7 +51,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
tickLine="none" tickLine="none"
tooltipAnimationDuration={0} tooltipAnimationDuration={0}
valueFormatter={formatChartValue} valueFormatter={formatChartValue}
withDots={vm.chartData.length === 1} withDots={chartData.length === 1}
withGradient withGradient
yAxisProps={{ domain: ["auto", "auto"], width: 64 }} yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
/> />
@@ -65,13 +64,13 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
)} )}
</Text> </Text>
{!!vm.hasIncompleteCandle && ( {!!hasIncompleteCandle && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.incomplete")} {t("stock.incomplete")}
</Text> </Text>
)} )}
{vm.averageLine !== null && ( {referenceLines.length > 0 && (
<Text mt="xs" size="sm"> <Text mt="xs" size="sm">
{t("stock.averageLine")}:{" "} {t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice)} {formatMoney(vm.position?.averagePurchasePrice)}
@@ -88,7 +87,7 @@ const InstrumentChart: FC<InstrumentChartProps> = (props) => {
<Text fw={600}>{t("stock.priceTable")}</Text> <Text fw={600}>{t("stock.priceTable")}</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("stock.priceTableHint", { count: vm.chartData.length })} {t("stock.priceTableHint", { count: chartData.length })}
</Text> </Text>
</Accordion.Control> </Accordion.Control>
@@ -1,16 +1,27 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib"; import { formatDate, formatDecimal, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types"; import type { InstrumentChartProps } from "../../model/types";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
function useInstrumentChartVm(props: InstrumentChartProps) { function useInstrumentChartVm(props: InstrumentChartProps) {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const priceRows = vm.priceRows.map((candle) => { const chartData = vm.candles?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value)
: null;
const priceRows = chartData.toReversed().map((candle) => {
const currency = vm.candles?.currency; const currency = vm.candles?.currency;
const value = candle.close; const value = candle.close;
@@ -39,20 +50,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
); );
const referenceLines const referenceLines
= vm.averageLine === null = averageLine === null || !Number.isFinite(averageLine)
? [] ? []
: [ : [
{ {
color: "orange.7", color: "orange.7",
ifOverflow: "extendDomain" as const, ifOverflow: "extendDomain" as const,
label: t("stock.averageLine"), label: t("stock.averageLine"),
y: vm.averageLine, y: averageLine,
}, },
]; ];
return { return {
chartData,
chartLabel, chartLabel,
formatChartValue, hasIncompleteCandle: chartData.some(candle => !candle.isComplete),
periodOptions, periodOptions,
priceRows, priceRows,
referenceLines, referenceLines,
@@ -321,6 +321,7 @@ describe("bond page", () => {
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible()); await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible());
expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument(); expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument();
expect(screen.queryByText(/Текущая средняя покупка/)).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }), screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }),
); );
+1 -14
View File
@@ -17,7 +17,7 @@ import {
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; const periods: BondPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "coupons", "other"] as const; const filters = ["all", "trades", "coupons", "other"] as const;
@@ -80,18 +80,9 @@ function useBondPageVm() {
setParameters(next, { replace: true }); setParameters(next, { replace: true });
}; };
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return { return {
averageLine:
null,
bond: bond.data, bond: bond.data,
bondState: section(bond, uid ? sync.retry : () => bond.refetch()), bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
candles: candles.data, candles: candles.data,
@@ -100,7 +91,6 @@ function useBondPageVm() {
bond.data?.slug && bond.data.slug !== bondId bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
: null, : null,
chartData,
code: apiErrorCode(bond.error), code: apiErrorCode(bond.error),
coupons: coupons.data, coupons: coupons.data,
couponsState: section(coupons, sync.retry), couponsState: section(coupons, sync.retry),
@@ -119,8 +109,6 @@ function useBondPageVm() {
?? null, ?? null,
})) ?? [], })) ?? [],
filter, filter,
hasIncompleteCandle:
candles.data?.candles.some(candle => !candle.isComplete) ?? false,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName), logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data, metrics: metrics.data,
@@ -143,7 +131,6 @@ function useBondPageVm() {
period, period,
position: position.data, position: position.data,
positionState: section(position, sync.retry), positionState: section(position, sync.retry),
priceRows: chartData.toReversed(),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.retry), quoteState: section(quote, sync.retry),
refresh: sync.refresh, refresh: sync.refresh,
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -78,6 +78,7 @@ const quote = {
function setup( function setup(
options: { options: {
ambiguousTicker?: boolean; ambiguousTicker?: boolean;
candles?: NonNullable<StockPageVm["candles"]>["candles"];
cursorError?: string; cursorError?: string;
failCompany?: boolean; failCompany?: boolean;
failStock?: boolean; failStock?: boolean;
@@ -124,7 +125,7 @@ function setup(
resource( resource(
{ {
candles: [], candles: options.candles ?? [],
currency: "RUB", currency: "RUB",
historyFrom: null, historyFrom: null,
observedAt: quote.observedAt, observedAt: quote.observedAt,
@@ -352,7 +353,10 @@ describe("stock page", () => {
).toBe(false); ).toBe(false);
}); });
it("opens by UID, displays server calculations and distinguishes company and personal dividends", async () => { it("opens by UID, displays server calculations and distinguishes company and personal dividends", async () => {
setup(); setup({ candles: [
{ close: "300.125", isComplete: true, time: "2026-09-09T00:00:00Z" },
{ close: "301.123456789", isComplete: false, time: "2026-09-10T00:00:00Z" },
] });
expect( expect(
await screen.findByRole("heading", { name: "Тестовая акция" }), await screen.findByRole("heading", { name: "Тестовая акция" }),
).toBeVisible(); ).toBeVisible();
@@ -376,6 +380,14 @@ describe("stock page", () => {
screen.getByRole("button", { name: "История дивидендов компании" }), screen.getByRole("button", { name: "История дивидендов компании" }),
); );
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible()); await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
const rows = within(await screen.findByRole("table")).getAllByRole("row");
expect(rows[1]).toHaveTextContent("10.09.2026");
expect(rows[1]).toHaveTextContent("301,123456789 ₽");
expect(rows[1]).toHaveTextContent("Формируется");
expect(rows[2]).toHaveTextContent("09.09.2026");
await waitFor(() => expect(screen.getByText("300,125 ₽")).toBeVisible());
}); });
it("keeps sold-position history and market content when company data fails", async () => { it("keeps sold-position history and market content when company data fails", async () => {
setup({ failCompany: true, noPosition: true }); setup({ failCompany: true, noPosition: true });
+1 -23
View File
@@ -17,7 +17,7 @@ import {
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const; const filters = ["all", "trades", "dividends", "other"] as const;
@@ -80,35 +80,16 @@ function useStockPageVm() {
setParameters(next, { replace: true }); 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)
: null;
const companyData = company.data; const companyData = company.data;
const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return { return {
averageLine:
averageLine !== null && Number.isFinite(averageLine)
? averageLine
: null,
candles: candles.data, candles: candles.data,
candlesState: section(candles, sync.retry), candlesState: section(candles, sync.retry),
canonicalTo: canonicalTo:
stock.data?.slug && stock.data.slug !== stockId stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: null, : null,
chartData,
code: apiErrorCode(stock.error), code: apiErrorCode(stock.error),
company: companyData, company: companyData,
companyState: section(company, sync.retry), companyState: section(company, sync.retry),
@@ -129,8 +110,6 @@ function useStockPageVm() {
?? null, ?? null,
})) ?? [], })) ?? [],
filter, filter,
hasIncompleteCandle:
candles.data?.candles.some(candle => !candle.isComplete) ?? false,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName), logoSrc: instrumentLogoUrl(stock.data?.logoName),
onNext: history.onNext, onNext: history.onNext,
@@ -151,7 +130,6 @@ function useStockPageVm() {
period, period,
position: position.data, position: position.data,
positionState: section(position, sync.retry), positionState: section(position, sync.retry),
priceRows: chartData.toReversed(),
quote: quote.data, quote: quote.data,
quoteState: section(quote, sync.retry), quoteState: section(quote, sync.retry),
refresh: sync.refresh, refresh: sync.refresh,