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