refactor: encapsulate instrument chart data
This commit is contained in:
@@ -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: "Прошедшие и недатированные купоны" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user