diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index 9ff7ecb..bef92b4 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -7,17 +7,13 @@ type Candles = NonNullable< 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 = { - averageLine: null | number; candles?: Candles; candlesState: SectionState; - chartData: ChartRow[]; - hasIncompleteCandle: boolean; period: string; position?: Position; - priceRows: ChartRow[]; setPeriod: (value: string) => void; }; @@ -41,4 +37,4 @@ type Position = NonNullable< paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] >; -export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; +export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx index 7aa826c..88cf2d7 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx @@ -10,20 +10,19 @@ import { Text, } 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 { InstrumentSection } from "../InstrumentSection.view"; import { useInstrumentChartVm } from "./InstrumentChart.vm"; -type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; - const InstrumentChart: FC = (props) => { const { + chartData, chartLabel, - formatChartValue, + hasIncompleteCandle, periodOptions, priceRows, referenceLines, @@ -34,14 +33,14 @@ const InstrumentChart: FC = (props) => { let chartContent; if (vm.candles) { - if (vm.chartData.length > 0) { + if (chartData.length > 0) { chartContent = ( <> = (props) => { tickLine="none" tooltipAnimationDuration={0} valueFormatter={formatChartValue} - withDots={vm.chartData.length === 1} + withDots={chartData.length === 1} withGradient yAxisProps={{ domain: ["auto", "auto"], width: 64 }} /> @@ -65,13 +64,13 @@ const InstrumentChart: FC = (props) => { )} - {!!vm.hasIncompleteCandle && ( + {!!hasIncompleteCandle && ( {t("stock.incomplete")} )} - {vm.averageLine !== null && ( + {referenceLines.length > 0 && ( {t("stock.averageLine")}:{" "} {formatMoney(vm.position?.averagePurchasePrice)} @@ -88,7 +87,7 @@ const InstrumentChart: FC = (props) => { {t("stock.priceTable")} - {t("stock.priceTableHint", { count: vm.chartData.length })} + {t("stock.priceTableHint", { count: chartData.length })} diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts index 02c6d0c..885c383 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -1,16 +1,27 @@ 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"; - -type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; +import type { InstrumentChartProps } from "../../model/types"; function useInstrumentChartVm(props: InstrumentChartProps) { const { vm } = props; 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 value = candle.close; @@ -39,20 +50,21 @@ function useInstrumentChartVm(props: InstrumentChartProps) { ); const referenceLines - = vm.averageLine === null + = averageLine === null || !Number.isFinite(averageLine) ? [] : [ { color: "orange.7", ifOverflow: "extendDomain" as const, label: t("stock.averageLine"), - y: vm.averageLine, + y: averageLine, }, ]; return { + chartData, chartLabel, - formatChartValue, + hasIncompleteCandle: chartData.some(candle => !candle.isComplete), periodOptions, priceRows, referenceLines, diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx index 994daf7..aa57007 100644 --- a/src/pages/bond/ui/BondPage/BondPage.test.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx @@ -321,6 +321,7 @@ describe("bond page", () => { fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible()); expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument(); + expect(screen.queryByText(/Текущая средняя покупка/)).not.toBeInTheDocument(); fireEvent.click( screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }), ); diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index a860caa..28e3178 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -17,7 +17,7 @@ import { } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatDate, instrumentLogoUrl } from "@/shared/lib"; +import { instrumentLogoUrl } from "@/shared/lib"; const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; const filters = ["all", "trades", "coupons", "other"] as const; @@ -80,18 +80,9 @@ function useBondPageVm() { 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()}` : ""; return { - averageLine: - null, bond: bond.data, bondState: section(bond, uid ? sync.retry : () => bond.refetch()), candles: candles.data, @@ -100,7 +91,6 @@ function useBondPageVm() { bond.data?.slug && bond.data.slug !== bondId ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` : null, - chartData, code: apiErrorCode(bond.error), coupons: coupons.data, couponsState: section(coupons, sync.retry), @@ -119,8 +109,6 @@ function useBondPageVm() { ?? null, })) ?? [], filter, - hasIncompleteCandle: - candles.data?.candles.some(candle => !candle.isComplete) ?? false, hasPrevious: history.hasPrevious, logoSrc: instrumentLogoUrl(bond.data?.logoName), metrics: metrics.data, @@ -143,7 +131,6 @@ function useBondPageVm() { period, position: position.data, positionState: section(position, sync.retry), - priceRows: chartData.toReversed(), quote: quote.data, quoteState: section(quote, sync.retry), refresh: sync.refresh, diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index b645944..2b90355 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; 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 { MemoryRouter, Route, Routes, useLocation } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -78,6 +78,7 @@ const quote = { function setup( options: { ambiguousTicker?: boolean; + candles?: NonNullable["candles"]; cursorError?: string; failCompany?: boolean; failStock?: boolean; @@ -124,7 +125,7 @@ function setup( resource( { - candles: [], + candles: options.candles ?? [], currency: "RUB", historyFrom: null, observedAt: quote.observedAt, @@ -352,7 +353,10 @@ describe("stock page", () => { ).toBe(false); }); 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( await screen.findByRole("heading", { name: "Тестовая акция" }), ).toBeVisible(); @@ -376,6 +380,14 @@ describe("stock page", () => { screen.getByRole("button", { name: "История дивидендов компании" }), ); 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 () => { setup({ failCompany: true, noPosition: true }); diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index fa3bd6d..d92ce0e 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -17,7 +17,7 @@ import { } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { formatDate, instrumentLogoUrl } from "@/shared/lib"; +import { instrumentLogoUrl } from "@/shared/lib"; const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; const filters = ["all", "trades", "dividends", "other"] as const; @@ -80,35 +80,16 @@ function useStockPageVm() { 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 search = parameters.size > 0 ? `?${parameters.toString()}` : ""; return { - averageLine: - averageLine !== null && Number.isFinite(averageLine) - ? averageLine - : null, candles: candles.data, candlesState: section(candles, sync.retry), canonicalTo: stock.data?.slug && stock.data.slug !== stockId ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` : null, - chartData, code: apiErrorCode(stock.error), company: companyData, companyState: section(company, sync.retry), @@ -129,8 +110,6 @@ function useStockPageVm() { ?? null, })) ?? [], filter, - hasIncompleteCandle: - candles.data?.candles.some(candle => !candle.isComplete) ?? false, hasPrevious: history.hasPrevious, logoSrc: instrumentLogoUrl(stock.data?.logoName), onNext: history.onNext, @@ -151,7 +130,6 @@ function useStockPageVm() { period, position: position.data, positionState: section(position, sync.retry), - priceRows: chartData.toReversed(), quote: quote.data, quoteState: section(quote, sync.retry), refresh: sync.refresh,