feat: add stock pages and ticker navigation

Add stock search and detail pages with charts, dividends and shared event rows.

Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards.

Update the generated API contract.
This commit is contained in:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
+51 -40
View File
@@ -3,53 +3,64 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query";
import {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client";
describe("useAuthenticatedQuery", () => {
it("keeps protected queries idle without a session and loads after session configuration", async () => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
}),
);
it.each(["single", "multiple"])(
"keeps %s protected queries idle without a session and loads after session configuration",
async (mode) => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
}),
);
const { result } = renderHook(
() =>
const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
),
{ wrapper: createQueryWrapper().wrapper },
);
);
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
const { result } = renderHook(
mode === "single" ? useSingleQuery : useMultipleQueries,
{ wrapper: createQueryWrapper().wrapper },
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle");
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce();
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce();
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
},
);
});
+29 -6
View File
@@ -2,6 +2,7 @@ import {
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueries,
useQuery,
} from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
@@ -18,11 +19,7 @@ function useAuthenticatedQuery<
>(
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryResult<TData, TError> {
const hasSession = useSyncExternalStore(
subscribeAuthenticatedSession,
getAuthenticatedSessionSnapshot,
getAuthenticatedSessionSnapshot,
);
const hasSession = useHasAuthenticatedSession();
const enabled = options.enabled;
return useQuery({
@@ -33,4 +30,30 @@ function useAuthenticatedQuery<
});
}
export { useAuthenticatedQuery };
function useHasAuthenticatedSession() {
return useSyncExternalStore(
subscribeAuthenticatedSession,
getAuthenticatedSessionSnapshot,
getAuthenticatedSessionSnapshot,
);
}
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
) {
const hasSession = useHasAuthenticatedSession();
return useQueries({
queries: queries.map(
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
...options,
enabled: (query) =>
hasSession &&
(typeof options.enabled === "function"
? options.enabled(query)
: (options.enabled ?? true)),
}),
),
});
}
export { useAuthenticatedQueries, useAuthenticatedQuery };
+812 -8
View File
@@ -84,6 +84,134 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/stocks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_search"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/by-ticker/{ticker}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_getByTicker"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/quote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_quote"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/candles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_candles"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/position": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_position"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/company": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_company"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/dividends": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_dividends"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tbank-connection": {
parameters: {
query?: never;
@@ -309,6 +437,151 @@ export interface components {
accessToken: string;
user: components["schemas"]["UserDto"];
};
MoneyDto: {
currency: string;
value: string;
};
StockSearchResultDto: {
/** Format: uuid */
instrumentUid: string;
/** @description Unambiguous ticker URL segment, or the exact instrument UID. */
slug: string;
name: string;
isin: string | null;
classCode: string | null;
realExchange: string | null;
apiTradeAvailable: boolean | null;
ticker: string | null;
logoName: string | null;
currency: string | null;
shareType: string | null;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */
priceAt: string | null;
};
StockDto: {
/** Format: uuid */
instrumentUid: string;
/** @description Unambiguous ticker URL segment, or the exact instrument UID. */
slug: string;
name: string;
isin: string | null;
classCode: string | null;
realExchange: string | null;
apiTradeAvailable: boolean | null;
ticker: string | null;
logoName: string | null;
currency: string | null;
shareType: string | null;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
};
StockQuoteDto: {
price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */
priceAt: string | null;
tradingStatus: string | null;
change: components["schemas"]["MoneyDto"] | null;
changePercent: string | null;
/** Format: date */
previousCloseDate: string | null;
previousClose: components["schemas"]["MoneyDto"] | null;
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
StockCandleDto: {
/** Format: date-time */
time: string;
close: string;
isComplete: boolean;
};
StockCandlesDto: {
candles: components["schemas"]["StockCandleDto"][];
currency: string | null;
/** Format: date-time */
historyFrom: string | null;
/** Format: date-time */
observedAt: string;
};
AccountReferenceDto: {
id: string;
name: string;
};
StockPositionRowDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null;
displayName: string;
/** @enum {string} */
type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER";
quantity: string;
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
isin: string | null;
ticker: string | null;
logoName: string | null;
returnPercent: string | null;
};
StockPositionDto: {
quantity: string;
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
returnPercent: string | null;
accounts: components["schemas"]["StockPositionRowDto"][];
isStale: boolean;
/** Format: date-time */
observedAt: string;
};
StockMetricDto: {
key: string;
value: number;
/** @enum {string} */
unit: "money" | "ratio" | "percent";
currency: string | null;
/** @enum {string|null} */
period: "TTM" | "MRQ" | null;
};
StockCompanyDto: {
description: string | null;
metrics: components["schemas"]["StockMetricDto"][];
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
StockDividendDto: {
perShare: components["schemas"]["MoneyDto"] | null;
/** Format: date */
recordDate: string | null;
/** Format: date */
lastBuyDate: string | null;
/** Format: date */
paymentDate: string | null;
/** @enum {string} */
timing: "upcoming" | "past" | "undated";
estimatedAmount: components["schemas"]["MoneyDto"] | null;
};
StockDividendsDto: {
dividends: components["schemas"]["StockDividendDto"][];
/** Format: date */
from: string;
/** Format: date */
to: string;
/** Format: date-time */
observedAt: string;
/** Format: date-time */
positionAt: string | null;
estimateUnavailable: boolean;
positionIsStale: boolean;
};
TbankConnectionStatusDto: {
connected: boolean;
/** Format: date-time */
@@ -317,12 +590,12 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
MoneyDto: {
currency: string;
value: string;
};
PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][];
/** @description Number of non-zero stock positions, not the number of units. */
stockPositionCount: number;
/** @description Number of non-zero bond positions, not the number of units. */
bondPositionCount: number;
};
BrokerageAccountDto: {
id: string;
@@ -333,10 +606,6 @@ export interface components {
openedOn: string | null;
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
};
AccountReferenceDto: {
id: string;
name: string;
};
BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string;
@@ -366,6 +635,7 @@ export interface components {
observedAt: string;
};
PortfolioPositionDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null;
displayName: string;
@@ -851,6 +1121,536 @@ export interface operations {
};
};
};
StockController_search: {
parameters: {
query: {
query: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockSearchResultDto"][];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_getByTicker: {
parameters: {
query?: never;
header?: never;
path: {
ticker: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_get: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_quote: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockQuoteDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_candles: {
parameters: {
query?: {
period?: "month" | "halfYear" | "year" | "all";
};
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockCandlesDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_position: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockPositionDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_company: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockCompanyDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_dividends: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDividendsDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
TbankConnectionController_status: {
parameters: {
query?: never;
@@ -1112,6 +1912,8 @@ export interface operations {
BrokerageOperationsController_all: {
parameters: {
query?: {
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string;
from?: string;
to?: string;
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
@@ -1182,6 +1984,8 @@ export interface operations {
BrokerageOperationsController_get: {
parameters: {
query?: {
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string;
from?: string;
to?: string;
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
+4 -1
View File
@@ -1,4 +1,7 @@
export { useAuthenticatedQuery } from "./authenticated-query";
export {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
export type { paths } from "./generated/schema";
export { apiErrorCode } from "./problem-details";
File diff suppressed because it is too large Load Diff
+176 -1
View File
@@ -1,7 +1,7 @@
const ru = {
translation: {
appName: "WealthLedger",
navigation: { accounts: "Счета", settings: "Настройки" },
navigation: { accounts: "Счета", stocks: "Акции", settings: "Настройки" },
breadcrumbs: {
label: "Хлебные крошки",
accounts: "Брокерские счета",
@@ -11,6 +11,163 @@ const ru = {
events: "Будущие события",
settings: "Настройки",
},
stock: {
searchTitle: "Акции",
searchSubtitle:
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
searchLabel: "Поиск акций",
searchPlaceholder: "Название или тикер, например Сбер или SBER",
searchHint: "Введите хотя бы два символа.",
searching: "Ищем акции",
noSearchResults:
"Акции не найдены. Попробуйте другое название или тикер.",
backToSearch: "Все акции",
searchVariants:
"Одна карточка на выпуск (ISIN). Обыкновенные и привилегированные акции показаны отдельно.",
apiUnavailable: "Торги через API недоступны",
apiAvailable: "Доступна через API",
exchangeUnknown: "Площадка не указана",
classCode: "Режим: {{code}}",
isin: "ISIN: {{value}}",
priceUnavailable: "Цена недоступна",
exchanges: {
REAL_EXCHANGE_MOEX: "Мосбиржа",
REAL_EXCHANGE_RTS: "СПБ Биржа",
REAL_EXCHANGE_OTC: "Внебиржевой рынок",
},
aboutCompany: "О компании",
priceTableHint: "Дневные закрытия · записей: {{count}} · сначала новые",
share: "Акция",
loading: "Загружаем страницу акции",
loadingSection: "Загружаем: {{section}}",
loadingPrice: "Загружаем текущую цену",
unavailable: "Акция недоступна",
ambiguousTicker:
"Этот тикер относится к нескольким выпускам. Выберите нужную акцию в поиске.",
unavailableText:
"Проверьте ссылку или попробуйте загрузить данные ещё раз.",
connect:
"Для просмотра акций подключите T-Банк в настройках или обновите подключение.",
sectionError: "Не удалось загрузить этот раздел. Попробуйте ещё раз.",
refreshPrice: "Обновить цену",
price: "Цена",
priceAt: "Цена на {{date}} UTC",
changeBase: "К закрытию предыдущего торгового дня, {{date}}",
noComparison:
"Изменение за день недоступно: нет подтверждённой цены сравнения.",
partialQuote: "Часть данных о цене или торгах временно недоступна.",
chart: "График цены",
period: "Период графика",
periods: {
month: "Месяц",
halfYear: "Полгода",
year: "Год",
all: "Вся история",
},
chartLabel: "Дневные цены акции, {{currency}}",
chartNote:
"Дневные цены по данным Т-Банка. Исторические цены могут учитывать корпоративные изменения.",
incomplete: "Последняя свеча ещё формируется: её цена может измениться.",
averageLine: "Текущая средняя покупка",
priceTable: "Цены в таблице",
date: "Дата",
candle: "Свеча",
complete: "Завершена",
forming: "Формируется",
noCandles:
"За этот период нет доступных цен. Возможно, торги ещё не начались.",
position: "Моя позиция",
quantity: "Количество акций",
value: "Текущая стоимость",
average: "Средняя покупка",
openReturn: "Результат позиции",
openReturnNote:
"Результат только открытой позиции: без проданных акций, дивидендов и полной доходности владения. «—» означает, что расчёт недоступен.",
portfolio: "Портфель",
snapshotAt: "Позиция на {{date}} UTC",
upcoming: "Предстоящие дивиденды",
companyEvent: "Событие компании",
perShare: "на акцию",
yourEstimate: "Оценка по вашей позиции",
record: "Дата реестра",
lastBuy: "Последний день покупки",
payment: "Выплата по данным источника",
noUpcoming: "Нет известных предстоящих выплат до {{to}}.",
dividendNote:
"Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.",
estimateUnavailable:
"Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.",
dividendHistory: "История дивидендов компании",
dividendWindow:
"События компании с {{from}} по {{to}}. Личные зачисления показаны отдельно в истории событий.",
noDividends: "Нет известных выплат за этот период.",
dateUnknown: "Дата не указана",
history: "Моя история событий",
historyNote:
"Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.",
eventFilter: "Фильтр событий",
filters: {
all: "Все",
trades: "Сделки",
dividends: "Дивиденды",
other: "Другие",
},
noOperations: "Нет доступных операций по выбранному фильтру.",
previous: "Назад",
next: "Далее",
eventQuantity: "{{value}} шт.",
eventPrice: "Цена: {{value}}",
company: "Компания и показатели",
sector: "Сектор",
lot: "Лот: {{value}} шт.",
partialCompany: "Часть сведений о компании временно недоступна.",
noFundamentals: "Нет доступных финансовых показателей.",
periodNote:
"TTM — последние 12 месяцев, MRQ — последний отчётный квартал. Дата отчётности не предоставлена источником. Отсутствующие значения не показаны.",
metrics: {
marketCapitalization: "Капитализация",
peRatioTtm: "P/E",
priceToBookTtm: "P/B",
revenueTtm: "Выручка",
ebitdaTtm: "EBITDA",
netIncomeTtm: "Чистая прибыль",
roe: "Рентабельность капитала (ROE)",
totalDebtMrq: "Общий долг",
netDebtToEbitda: "Чистый долг / EBITDA",
},
shareTypes: {
SHARE_TYPE_COMMON: "Обыкновенная акция",
SHARE_TYPE_PREFERRED: "Привилегированная акция",
SHARE_TYPE_ADR: "Депозитарная расписка",
SHARE_TYPE_GDR: "Депозитарная расписка",
},
sectors: {
financial: "Финансы",
energy: "Энергетика",
materials: "Материалы",
industrials: "Промышленность",
utilities: "Коммунальные услуги",
telecom: "Телекоммуникации",
it: "Информационные технологии",
consumer: "Потребительский сектор",
health_care: "Здравоохранение",
real_estate: "Недвижимость",
},
trading: {
unknown: "Статус торгов неизвестен",
SECURITY_TRADING_STATUS_NORMAL_TRADING: "Идут торги",
SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING: "Торги недоступны",
SECURITY_TRADING_STATUS_OPENING_PERIOD: "Период открытия",
SECURITY_TRADING_STATUS_CLOSING_PERIOD: "Период закрытия",
SECURITY_TRADING_STATUS_BREAK_IN_TRADING: "Перерыв в торгах",
SECURITY_TRADING_STATUS_CLOSING_AUCTION: "Аукцион закрытия",
SECURITY_TRADING_STATUS_OPENING_AUCTION: "Аукцион открытия",
SECURITY_TRADING_STATUS_SESSION_OPEN: "Сессия открыта",
SECURITY_TRADING_STATUS_SESSION_CLOSE: "Сессия закрыта",
SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING: "Дилерские торги",
},
},
auth: {
login: "Вход",
register: "Регистрация",
@@ -33,6 +190,10 @@ const ru = {
scope: "Счёт",
allDescription: "Портфель по всем доступным инвестиционным счетам",
title: "Счета",
subtitle: "Ваши инвестиции по счетам",
currentValue: "Текущая стоимость",
stocks: "Акции",
bonds: "Облигации",
emptyTitle: "Нет инвестиционных счетов",
emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
@@ -120,6 +281,20 @@ const ru = {
share: "Доля: {{value}}",
},
ledger: {
occurredAt: "{{date}} UTC",
quantity: "{{value}} шт.",
price: "Цена: {{value}}",
operationType: {
buy: "Покупка",
sell: "Продажа",
dividend: "Зачисление дивидендов",
tax: "Налог",
fee: "Комиссия",
input: "Зачисление ценных бумаг",
output: "Вывод ценных бумаг",
transfer: "Перевод между счетами",
other: "Другая операция",
},
title: "Журнал операций",
back: "К счету",
open: "Открыть журнал",
+14 -1
View File
@@ -1,7 +1,20 @@
import { describe, expect, it } from "vitest";
import { formatMoney, instrumentLogoUrl } from "./display";
import {
formatDate,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
describe("shared display helpers", () => {
it("uses padded Russian dates and preserves the UTC date of timestamps", () => {
expect(formatDate("2027-09-10")).toBe("10.09.2027");
expect(formatDate("2024-02-29")).toBe("29.02.2024");
expect(formatDate("2027-09-10T00:30:00+03:00")).toBe("09.09.2027");
expect(formatSnapshotTime("2027-09-10T00:30:00+03:00")).toBe(
"09.09.2027, 21:30",
);
});
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
"-1 234 567,89 ₽",
+17 -2
View File
@@ -15,9 +15,18 @@ function formatMoney(money: Money | null): string {
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}).format(new Date(value));
}
function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
dateStyle: "short",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
@@ -32,4 +41,10 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null {
: null;
}
export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl };
export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
};
+1
View File
@@ -1,4 +1,5 @@
export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
+1
View File
@@ -14,6 +14,7 @@ const AppShell: FC<AppShellProps> = (props) => {
const { pathname } = useLocation();
const items = [
{ to: "/accounts", label: t("navigation.accounts") },
{ to: "/stocks", label: t("navigation.stocks") },
{ to: "/settings", label: t("navigation.settings") },
];
+3 -1
View File
@@ -1,10 +1,11 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
initial: string;
imageSrc?: string | null;
value: string;
@@ -33,6 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
<Text c="dimmed" size="sm">
{props.metadata}
</Text>
{props.details}
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>