feat: show persisted investment data and sync state
Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes.
This commit is contained in:
@@ -6,6 +6,7 @@ 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 { StockPage } from "./StockPage";
|
||||
|
||||
@@ -80,100 +81,112 @@ function setup(
|
||||
http.get("*/api/v1/stocks/:uid", () =>
|
||||
options.failStock
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resolved),
|
||||
: HttpResponse.json(resource(resolved)),
|
||||
),
|
||||
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(resolved);
|
||||
: HttpResponse.json(resource(resolved));
|
||||
}),
|
||||
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
if (params.section === "quote")
|
||||
return failQuote
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(quote);
|
||||
: HttpResponse.json(resource(quote));
|
||||
if (params.section === "position")
|
||||
return HttpResponse.json(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
resource(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
),
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json({
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
candles: [],
|
||||
currency: "RUB",
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
if (params.section === "company")
|
||||
return options.failCompany
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
unit: "ratio",
|
||||
currency: null,
|
||||
period: "TTM",
|
||||
},
|
||||
],
|
||||
unavailable: [],
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
return HttpResponse.json({
|
||||
dividends: [
|
||||
{
|
||||
perShare: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
lastBuyDate: "2026-10-12",
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perShare: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
lastBuyDate: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
});
|
||||
: HttpResponse.json(
|
||||
resource({
|
||||
description: "Описание компании",
|
||||
metrics: [
|
||||
{
|
||||
key: "peRatioTtm",
|
||||
value: 5.6,
|
||||
unit: "ratio",
|
||||
currency: null,
|
||||
period: "TTM",
|
||||
},
|
||||
],
|
||||
unavailable: [],
|
||||
observedAt: quote.observedAt,
|
||||
}),
|
||||
);
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
dividends: [
|
||||
{
|
||||
perShare: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
lastBuyDate: "2026-10-12",
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perShare: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
lastBuyDate: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const next = url.searchParams.has("cursor");
|
||||
return HttpResponse.json({
|
||||
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_DIVIDEND",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
});
|
||||
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_DIVIDEND",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
@@ -271,9 +284,11 @@ describe("stock page", () => {
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить этот раздел",
|
||||
);
|
||||
expect(
|
||||
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
||||
const { requests } = setup();
|
||||
@@ -308,14 +323,38 @@ describe("stock page", () => {
|
||||
);
|
||||
});
|
||||
it("keeps the last quote on a refresh failure", async () => {
|
||||
const controls = setup();
|
||||
const scopes: unknown[] = [];
|
||||
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: "Обновить цену" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"последние полученные",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
|
||||
expect(
|
||||
(await screen.findByText(/последние полученные/)).closest(
|
||||
'[role="status"]',
|
||||
),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("300 ₽")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
{
|
||||
screen: "stock",
|
||||
instrumentUid: uid,
|
||||
accountId: "all",
|
||||
candlePeriod: "year",
|
||||
categories: ["trade"],
|
||||
limit: 20,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("offers recovery for an unavailable direct link", async () => {
|
||||
setup({ failStock: true });
|
||||
|
||||
@@ -63,12 +63,10 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
vm.stockState.retry();
|
||||
vm.quoteState.retry();
|
||||
}}
|
||||
onClick={vm.refresh}
|
||||
disabled={vm.refreshing}
|
||||
>
|
||||
{t("stock.refreshPrice")}
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
{vm.company?.description ? (
|
||||
@@ -76,8 +74,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
{vm.company.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{vm.stockState.error || vm.quoteState.error ? (
|
||||
<Alert role="alert" color="orange" mt="md">
|
||||
{vm.refreshError || vm.stockState.error || vm.quoteState.error ? (
|
||||
<Alert role="status" aria-live="polite" color="orange" mt="md">
|
||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -164,7 +162,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
{vm.company ? (
|
||||
<Stack gap="lg">
|
||||
{vm.company.unavailable.length ? (
|
||||
<Alert color="orange">
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("stock.partialCompany")}</Text>
|
||||
<Button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useStockQuote,
|
||||
} from "@/entities/stock";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -58,6 +59,17 @@ function useStockPageVm() {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "stock",
|
||||
instrumentUid: uid,
|
||||
candlePeriod: period,
|
||||
accountId: "all",
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
},
|
||||
[stock, quote, candles, position, company, dividends, operations],
|
||||
);
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(key, value);
|
||||
@@ -70,7 +82,7 @@ function useStockPageVm() {
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void operations.refetch();
|
||||
} else void sync.refresh();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
@@ -85,20 +97,23 @@ function useStockPageVm() {
|
||||
: null;
|
||||
const companyData = company.data;
|
||||
return {
|
||||
refresh: sync.refresh,
|
||||
refreshing: sync.refreshing,
|
||||
refreshError: sync.transportError || sync.sourceError,
|
||||
canonicalTo:
|
||||
stock.data?.slug && stock.data.slug !== stockId
|
||||
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
stock: stock.data,
|
||||
stockState: section(stock),
|
||||
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
|
||||
code: apiErrorCode(stock.error),
|
||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote),
|
||||
quoteState: section(quote, sync.refresh),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles),
|
||||
candlesState: section(candles, sync.refresh),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine:
|
||||
@@ -106,11 +121,11 @@ function useStockPageVm() {
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position),
|
||||
positionState: section(position, sync.refresh),
|
||||
company: companyData,
|
||||
companyState: section(company),
|
||||
companyState: section(company, sync.refresh),
|
||||
dividends: dividends.data,
|
||||
dividendsState: section(dividends),
|
||||
dividendsState: section(dividends, sync.refresh),
|
||||
upcoming:
|
||||
dividends.data?.dividends
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
@@ -128,7 +143,10 @@ function useStockPageVm() {
|
||||
),
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: { ...section(operations), retry: retryOperations },
|
||||
operationsState: {
|
||||
...section(operations, sync.refresh),
|
||||
retry: retryOperations,
|
||||
},
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
...item,
|
||||
|
||||
@@ -23,7 +23,9 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
{vm.dividends ? (
|
||||
<Stack gap="md">
|
||||
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
|
||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
||||
<Alert color="orange" role="status" aria-live="polite">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{vm.upcoming.length ? (
|
||||
vm.upcoming.map((event) => (
|
||||
|
||||
Reference in New Issue
Block a user