refactor: clarify frontend model ownership
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m20s

This commit is contained in:
2026-09-28 21:44:53 +03:00
parent 7e976c330c
commit 0865b35f93
44 changed files with 685 additions and 710 deletions
@@ -238,6 +238,34 @@ describe("All accounts routes", () => {
);
});
it.each(["operations", "events", "income"])(
"restores applied dates when switching accounts in %s",
async (section) => {
const search = "?from=2026-08-01&to=2026-08-31";
renderPage(`/accounts/all/${section}${search}`);
fireEvent.click(await screen.findByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: /10 August 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /31 August 2026/i }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
"10.08.2026 – 31.08.2026",
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.08.2026 – 31.08.2026",
),
);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/${section}${search}`,
);
},
);
it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -312,6 +340,11 @@ describe("All accounts routes", () => {
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"/accounts/b/operations?from=2026-08-01&to=2026-08-31&categories=income",
);
await screen.findByText("Первая страница");
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "Все счета" }));
await waitFor(() => expect(screen.getByText("Первая страница")).toBeVisible());
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
});
it("restarts an expired aggregate cursor without losing filters", async () => {
-36
View File
@@ -200,42 +200,6 @@ const AppRoutes: FC = () => (
path="/accounts"
/>
<Route
element={(
<LazyPage variant="content">
<AccountPage accountId="all" />
</LazyPage>
)}
path="/accounts/all"
/>
<Route
element={(
<LazyPage variant="content">
<AccountOperationsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/operations"
/>
<Route
element={(
<LazyPage variant="content">
<AccountIncomePage accountId="all" />
</LazyPage>
)}
path="/accounts/all/income"
/>
<Route
element={(
<LazyPage variant="content">
<AccountEventsPage accountId="all" />
</LazyPage>
)}
path="/accounts/all/events"
/>
<Route
element={(
<LazyPage variant="content">
+6
View File
@@ -2,6 +2,10 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const MIN_BOND_SEARCH_LENGTH = 2;
type BondCoupons = NonNullable<paths["/api/v1/bonds/{uid}/coupons"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondMetrics = NonNullable<paths["/api/v1/bonds/{uid}/metrics"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type BondPeriod = NonNullable<
NonNullable<
paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"]
@@ -104,6 +108,8 @@ function useBondSearch(query: string) {
}
export {
type BondCoupons,
type BondMetrics,
type BondPeriod,
MIN_BOND_SEARCH_LENGTH,
useBond,
+2
View File
@@ -1,4 +1,6 @@
export {
type BondCoupons,
type BondMetrics,
type BondPeriod,
MIN_BOND_SEARCH_LENGTH,
useBond,
@@ -68,6 +68,7 @@ function useBrokerageOperations(
export {
type BrokerageOperation,
type BrokerageOperationsQuery,
brokerageOperationsQueryOptions,
brokerageOperationsQueryParameters as brokerageOperationsQueryParams,
operationCategories,
+1
View File
@@ -5,4 +5,5 @@ export {
type OperationCategory,
useBrokerageOperations,
} from "./api/useBrokerageOperations";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
@@ -1,19 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
function useInstrumentOperations(
uid: string,
filter: string,
categories: OperationCategory[],
import {
type BrokerageOperationsQuery,
brokerageOperationsQueryOptions,
useBrokerageOperations,
} from "../api/useBrokerageOperations";
function useBrokerageOperationHistory(
accountId: string | undefined,
firstPage: Omit<BrokerageOperationsQuery, "cursor">,
) {
const scope = `${uid}:${filter}`;
const { queryKey } = brokerageOperationsQueryOptions(accountId, firstPage);
const scope = hashKey(queryKey);
const [pagination, setPagination] = useState<{
cursors: string[];
@@ -22,13 +23,14 @@ function useInstrumentOperations(
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const firstPage = { categories, instrumentUid: uid, limit: 20 };
const operations = useBrokerageOperations(uid ? "all" : undefined, {
const operations = useBrokerageOperations(accountId, {
...firstPage,
cursor: cursors.at(-1),
});
const reset = () => setPagination({ cursors: [], scope });
const retry = (retryScreen: () => unknown) => {
if (
isApiErrorCode(
@@ -36,10 +38,10 @@ function useInstrumentOperations(
ApiErrorCode.InvalidOperationsCursor,
)
) {
setPagination({ cursors: [], scope });
reset();
void client.invalidateQueries({
exact: true,
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
queryKey,
});
}
else void retryScreen();
@@ -56,8 +58,9 @@ function useInstrumentOperations(
},
onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }),
operations,
reset,
retry,
};
}
export { useInstrumentOperations };
export { useBrokerageOperationHistory };
@@ -1,6 +1,7 @@
import type { InvestmentResourceMeta } from "@/shared/api";
import type {
BrokerageAccount,
useBrokerageAccounts,
} from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2;
@@ -9,11 +10,7 @@ type BrokerageAccountCard = {
account: BrokerageAccount;
positionCounts: null
| { bonds: number; stocks: number };
source?: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined
? T
: never;
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
summary:
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
| { state: "empty" }
@@ -30,9 +27,7 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards(
accounts: BrokerageAccount[],
dependencies: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] = [],
dependencies: InvestmentResourceMeta["dependencies"] = [],
): BrokerageAccountCard[] {
return accounts.map((account) => {
const source = dependencies.find(
+4
View File
@@ -4,6 +4,9 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
@@ -44,6 +47,7 @@ function useIncomeSummary(accountId?: string) {
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
useIncomeSummary,
+1
View File
@@ -1,5 +1,6 @@
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
useIncomeSummary,
+1 -2
View File
@@ -1,5 +1,4 @@
export { section } from "./model/section";
export { useInstrumentOperations } from "./model/useInstrumentOperations";
export { section, type SectionState } from "./model/section";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
@@ -1,7 +1,4 @@
import type { paths } from "@/shared/api";
type Meta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
import type { InvestmentResourceMeta } from "@/shared/api";
type SectionState = {
error: boolean;
@@ -20,7 +17,7 @@ function section(
data: unknown;
isError: boolean;
isFetching: boolean;
meta?: Meta;
meta?: InvestmentResourceMeta;
refetch: () => unknown;
},
retry?: () => unknown,
@@ -6,18 +6,15 @@ import {
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
meta?: ResourceMeta;
meta?: InvestmentResourceMeta;
observe: (version?: string) => Promise<unknown>;
queryKey: QueryKey;
transport: {
@@ -34,7 +31,7 @@ const STATUS_INTERVAL_MS = 1000;
function observeResource(
resource: ScreenResource,
latest: Map<string, ResourceMeta>,
latest: Map<string, InvestmentResourceMeta>,
completedId: string | undefined,
observed: Map<string, string>,
) {
@@ -143,7 +140,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
invalidateResourceAccess(client, "brokerage", status.error);
}
const latest = new Map<string, ResourceMeta>();
const latest = new Map<string, InvestmentResourceMeta>();
const orderedStatuses = statuses.toSorted(
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
@@ -146,12 +146,14 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
() => useAccountEventsPageViewModel(),
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
<MemoryRouter initialEntries={[`/accounts/all/events?${search}`]}>
<Routes>
<Route element={children} path="/accounts/:accountId/events" />
</Routes>
</MemoryRouter>
</QueryClientProvider>
),
@@ -326,10 +328,14 @@ it("retains the previous complete forecast and timestamp when its source refresh
);
const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter>{children}</MemoryRouter>
<MemoryRouter initialEntries={["/accounts/all/events"]}>
<Routes>
<Route element={children} path="/accounts/:accountId/events" />
</Routes>
</MemoryRouter>
</QueryClientProvider>
),
});
@@ -5,10 +5,8 @@ import { AccountEventsPageError } from "./components/AccountEventsPageError.view
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
const AccountEventsPage: FC = () => {
const viewModel = useAccountEventsPageViewModel();
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return viewModel.state === "error"
@@ -27,11 +27,8 @@ type AccountEventsPageViewModel
| { onReset: () => void; retry: () => void; state: "error" }
| { state: "loading" };
function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
@@ -59,13 +56,11 @@ function useAccountEventsPageViewModel(
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
]);
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope);
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
setPreviousPeriod([appliedFrom, appliedTo]);
if (previousScope !== scope) {
setPreviousScope(scope);
setDraftPeriod([appliedFrom, appliedTo]);
}
+3 -5
View File
@@ -1,4 +1,4 @@
import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
import { formatDate } from "@/shared/lib";
@@ -29,8 +29,6 @@ type IncomeDisplayModel = {
yearNotice: ComparisonNotice;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
@@ -63,7 +61,7 @@ function comparisonDisplay(
function comparisonNotice(
dependency:
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
IncomeAnalyticsMeta["dependencies"][number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
@@ -98,7 +96,7 @@ function formatIncomeChange(
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
@@ -5,10 +5,8 @@ import { AccountIncomePageError } from "./components/AccountIncomePageError.view
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC<AccountIncomePageProps> = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
const AccountIncomePage: FC = () => {
const viewModel = useAccountIncomePageViewModel();
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return viewModel.state === "error"
@@ -44,11 +44,8 @@ type AccountIncomePageViewModel
}
| { state: "loading" };
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams();
const requested = useMemo(
@@ -83,7 +80,7 @@ const useAccountIncomePageViewModel = (
}, [period, requested, setSearchParameters]);
useEffect(() => {
form.reset(draftPeriod(period));
}, [form, period]);
}, [accountId, form, period]);
if (analytics.isLoading || !analytics.data) {
return analytics.isError || analytics.meta?.sync.state === "failed"
@@ -117,7 +114,7 @@ const useAccountIncomePageViewModel = (
analytics: analytics.data,
display,
form,
formRevision: `${period.from}:${period.to}:${resetRevision}`,
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset,
onSubmit,
@@ -5,10 +5,8 @@ import { AccountOperationsPageError } from "./components/AccountOperationsPageEr
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
type AccountOperationsPageProps = { accountId?: string };
const AccountOperationsPage: FC<AccountOperationsPageProps> = (props) => {
const viewModel = useAccountOperationsPageVm(props.accountId);
const AccountOperationsPage: FC = () => {
const viewModel = useAccountOperationsPageVm();
if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />;
@@ -7,7 +7,7 @@ import {
brokerageOperationsQueryOptions,
operationCategories,
type OperationCategory,
useBrokerageOperations,
useBrokerageOperationHistory,
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -41,11 +41,8 @@ type AccountOperationsPageViewModel
| { onReset: () => void; retry: () => void; state: "error" }
| { state: "loading" };
const useAccountOperationsPageVm = (
scopeId?: string,
): AccountOperationsPageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams();
const queryClient = useQueryClient();
const [searchParameters, setSearchParameters] = useSearchParams();
const urlFrom = searchParameters.get("from");
@@ -74,25 +71,6 @@ const useAccountOperationsPageVm = (
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
urlFrom,
urlTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
cursors: string[];
scopeKey: string;
}>({ cursors: [], scopeKey });
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
if (pagination.scopeKey !== scopeKey) {
setPagination({ cursors: [], scopeKey });
}
useEffect(() => {
if (isValidUrlPeriod) return;
const normalized = new URLSearchParams(searchParameters);
@@ -100,13 +78,6 @@ const useAccountOperationsPageVm = (
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
const [previousSearch, setPreviousSearch] = useState(searchParameters);
if (previousSearch !== searchParameters) {
setPreviousSearch(searchParameters);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories(appliedCategories);
}
const firstPageQuery = {
from: dateStart(appliedFrom),
@@ -115,10 +86,17 @@ const useAccountOperationsPageVm = (
limit: 100,
};
const operations = useBrokerageOperations(accountId, {
...firstPageQuery,
...(cursor && { cursor }),
});
const history = useBrokerageOperationHistory(accountId, firstPageQuery);
const { operations } = history;
const scope = JSON.stringify([accountId, searchParameters.toString()]);
const [previousScope, setPreviousScope] = useState(scope);
if (previousScope !== scope) {
setPreviousScope(scope);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories(appliedCategories);
history.reset();
}
const sync = useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery },
@@ -131,30 +109,13 @@ const useAccountOperationsPageVm = (
limit: 100,
}).queryKey;
const resetCursors = () => setPagination({ cursors: [], scopeKey });
const retry = () => {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
void queryClient.invalidateQueries({
exact: true,
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
.queryKey,
});
resetCursors();
}
else void sync.retry();
};
const retry = () => history.retry(sync.retry);
const onReset = () => {
setDraftPeriod([null, null]);
setDraftCategories([]);
setSearchParameters({});
resetCursors();
history.reset();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
@@ -180,7 +141,7 @@ const useAccountOperationsPageVm = (
draftCategories,
draftPeriod,
hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: cursors.length > 0,
hasPrevious: history.hasPrevious,
historyLoading:
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
@@ -195,20 +156,16 @@ const useAccountOperationsPageVm = (
categories: draftCategories.join(","),
}),
});
resetCursors();
history.reset();
},
onCategoriesChange: values => setDraftCategories(
values.filter((value): value is OperationCategory =>
operationCategorySet.has(value),
),
),
onNext: () => {
if (nextCursor)
setPagination({ cursors: [...cursors, nextCursor], scopeKey });
},
onNext: history.onNext,
onPeriodChange: setDraftPeriod,
onPrevious: () =>
setPagination({ cursors: cursors.slice(0, -1), scopeKey }),
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.availability !== "complete",
refreshError: operations.isError || sync.sourceError || sync.transportError,
+177
View File
@@ -0,0 +1,177 @@
import { formatShare, type Portfolio } from "@/entities/portfolio";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string;
logoSrc: null | string;
returnText: null | string;
returnTone?: AccountPageReturnTone;
shareText: null | string;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
groups: AccountPagePositionGroup[];
kind: PositionSectionKind;
};
type AccountPageReturnTone = "negative" | "positive";
type PositionSectionKind = "bonds"
| "other"
| "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(
item => item.account?.id !== position.account?.id,
)
) {
group.positions.push(position);
}
else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim())
return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, undefined
| { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
denominators.set(
currency,
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection(
"stocks",
portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0)
returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null,
};
}
function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined
| { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY";
})
.map(position =>
toPosition(
position,
denominators,
kind,
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
kind,
};
}
export {
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
toAccountPagePositionSections,
};
@@ -24,8 +24,8 @@ import type {
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { AccountPage } from "./AccountPage";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
@@ -39,13 +39,13 @@ function configureTestAuth() {
});
}
function renderAccountRoute(queryClient: QueryClient) {
function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
configureTestAuth();
return render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
@@ -202,7 +202,7 @@ describe("AccountPage states", () => {
}),
);
configureTestAuth();
renderPage(<AccountPage accountId={accountId} />);
renderAccountRoute(createTestQueryClient(), accountId);
await waitFor(() => {
const links = screen.getAllByRole("link", {
name: position.displayName,
@@ -6,10 +6,8 @@ import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
type AccountPageProps = { accountId?: string };
const AccountPage: FC<AccountPageProps> = (props) => {
const viewModel = useAccountPageViewModel(props.accountId);
const AccountPage: FC = () => {
const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
@@ -13,10 +13,8 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
toAccountPagePositionSections,
useAccountPageViewModel,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { useAccountPageViewModel } from "./AccountPage.vm";
const portfolio: Portfolio
= {
@@ -13,11 +13,12 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions";
const RECENT_EVENTS_LIMIT = 5;
@@ -58,24 +59,6 @@ type AccountPageLoadedViewModel = AccountPageContent & {
state: "loaded";
};
type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string;
logoSrc: null | string;
returnText: null | string;
returnTone?: AccountPageReturnTone;
shareText: null | string;
};
type AccountPagePositionGroup = {
key: string;
positions: AccountPagePosition[];
};
type AccountPagePositionSection = {
groups: AccountPagePositionGroup[];
kind: PositionSectionKind;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageRecentOperations
@@ -94,89 +77,12 @@ type AccountPageRecentOperations
to: string;
};
type AccountPageReturnTone = "negative" | "positive";
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | undefined; state: "error" }
| { state: "loading" };
type PositionSectionKind = "bonds"
| "other"
| "stocks";
function groupPositions(
positions: AccountPagePosition[],
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = positionIdentity(position);
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key
= identity && position.account
? JSON.stringify([position.type, ...identity])
: rowKey;
const group = groups.get(key);
if (
group
&& group.positions.every(
item => item.account?.id !== position.account?.id,
)
) {
group.positions.push(position);
}
else {
const uniqueKey = group ? rowKey : key;
groups.set(uniqueKey, { key: uniqueKey, positions: [position] });
}
}
// eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline.
return [...groups.values()];
}
function positionIdentity(position: AccountPagePosition) {
if (position.instrumentUid?.trim())
return ["uid", position.instrumentUid.trim()];
if (position.figi?.trim()) return ["figi", position.figi.trim()];
return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined;
}
function toAccountPagePositionSections(
portfolio: Portfolio,
instrumentDetails: Map<string, undefined
| { slug: string }> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
if (!position.currentValue) continue;
const currency = position.currentValue.currency;
denominators.set(
currency,
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection(
"stocks",
portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter(section => section.groups.length > 0);
}
function toIncomeSummary(
accountId: string | undefined,
income: ReturnType<typeof useIncomeSummary>,
@@ -199,47 +105,6 @@ function toIncomeSummary(
};
}
function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
instrumentSlug?: string,
): AccountPagePosition {
const isExpanded = kind !== "other";
const returnValue = position.absoluteReturn
? Number(position.absoluteReturn.value)
: null;
let returnTone: AccountPageReturnTone | undefined;
if (returnValue !== null && returnValue !== 0)
returnTone = returnValue > 0 ? "positive" : "negative";
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
return {
...position,
instrumentTo:
(kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined,
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null,
returnTone,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
value: String(denominators.get(position.currentValue.currency) ?? 0),
})
: null,
};
}
function toRecentEvents(
accountId: string | undefined,
events: ReturnType<typeof useBrokerageEvents>,
@@ -299,41 +164,8 @@ function toRecentOperations(
};
}
function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
instrumentDetails: Map<string, undefined
| { slug: string }>,
): AccountPagePositionSection {
return {
groups: groupPositions(
portfolio.positions
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
return kind === "bonds"
? position.type === "BOND"
: position.type !== "STOCK"
&& position.type !== "BOND"
&& position.type !== "CURRENCY";
})
.map(position =>
toPosition(
position,
denominators,
kind,
instrumentDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
kind,
};
}
function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const portfolio = usePortfolio(accountId);
const stocks = useStocks(
@@ -344,8 +176,6 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
) ?? [],
);
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
@@ -408,7 +238,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(
portfolio.data,
instrumentDetails,
stocks,
),
recentEvents,
recentOperations,
@@ -419,11 +249,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
export {
type AccountPageIncomeSummary,
type AccountPagePosition,
type AccountPagePositionGroup,
type AccountPagePositionSection,
type AccountPageRecentEvents,
type AccountPageRecentOperations,
toAccountPagePositionSections,
useAccountPageViewModel,
};
@@ -9,12 +9,8 @@ import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import type {
AccountPageIncomeSummary,
AccountPagePositionSection,
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "../AccountPage.vm";
import type { AccountPagePositionSection } from "../../../model/positions";
import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations } from "../AccountPage.vm";
import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { AccountPagePosition } from "../../AccountPage.vm";
import type { AccountPagePosition } from "../../../../model/positions";
import { PositionCard } from "./PositionCard.view";
@@ -6,10 +6,7 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "../../AccountPage.vm";
import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions";
import classes from "../../AccountPage.module.css";
import { PositionLogo } from "../PositionLogo.view";
@@ -6,10 +6,7 @@ import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import type {
AccountPagePositionGroup,
AccountPagePositionSection,
} from "../AccountPage.vm";
import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions";
import classes from "../AccountPage.module.css";
import { PositionLogo } from "./PositionLogo.view";
@@ -1,10 +1,9 @@
import type { FC } from "react";
import { Anchor, Avatar } from "@mantine/core";
import { useState } from "react";
import { Link } from "react-router";
import { useImageFallback } from "@/shared/lib";
type PositionLogoProps = {
label: string;
src: null | string;
@@ -13,7 +12,9 @@ type PositionLogoProps = {
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { label, src } = props;
const { failed, onError } = useImageFallback();
const [failed, setFailed] = useState(false);
// eslint-disable-next-line frontend/readability -- Rendering fallback belongs to this keyed logo, without page data or actions.
const onError = () => setFailed(true);
const logo
= src && !failed
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { SimpleGrid } from "@mantine/core";
import type { AccountPagePositionSection } from "../AccountPage.vm";
import type { AccountPagePositionSection } from "../../../model/positions";
import { PositionCard } from "./PositionCard/PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { toAccountPagePositionSections } from "./AccountPage.vm";
import { toAccountPagePositionSections } from "../../model/positions";
import { PositionSection } from "./components/PositionSection.view";
const position: Portfolio["positions"][number]
@@ -8,10 +8,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { afterEach, expect, it } from "vitest";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "./AccountPage.vm";
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
import { PositionSection } from "./components/PositionSection.view";
+10 -284
View File
@@ -1,9 +1,7 @@
import type { FC } from "react";
import {
Accordion,
Alert,
Anchor,
Avatar,
Badge,
Button,
@@ -21,7 +19,6 @@ import {
InstrumentChart,
InstrumentEvents,
InstrumentPosition,
InstrumentSection,
} from "@/features/instrument-details";
import {
formatDate,
@@ -33,6 +30,9 @@ import {
import type { BondPageVm } from "./BondPage.vm";
import classes from "./BondPage.module.css";
import { BondCoupons } from "./components/BondCoupons.view";
import { BondMetrics } from "./components/BondMetrics.view";
import { Metric } from "./components/Metric.view";
type BondPageViewProps = { vm: BondPageVm };
@@ -226,7 +226,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</div>
</Paper>
<BondMetrics vm={vm} />
<BondMetrics metrics={vm.metrics} state={vm.metricsState} />
<InstrumentChart priceUnit="percent" vm={vm} />
@@ -236,7 +236,12 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
vm={vm}
/>
<BondCoupons vm={vm} />
<BondCoupons
coupons={vm.coupons}
pastCoupons={vm.pastCoupons}
state={vm.couponsState}
upcoming={vm.upcoming}
/>
<InstrumentEvents
historyNote={t("bond.historyNote")}
@@ -248,283 +253,4 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
);
};
const BondMetrics: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const creditRating = vm.metrics?.creditRating;
let rating = t(
vm.metrics?.unavailable.includes("rating")
? "bond.ratingUnavailable"
: "bond.ratingMissing",
);
if (creditRating) {
rating
= creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (creditRating.value ?? t("bond.unknown"));
}
const couponDate = vm.metrics?.couponDate
? formatDate(vm.metrics?.couponDate)
: t("bond.unknown");
const effectiveYield = vm.metrics?.effectiveYield
? `${formatDecimal(vm.metrics?.effectiveYield.value)} %`
: t("bond.unknown");
const couponYield
= vm.metrics?.couponYieldPercent == undefined
? t("bond.unknown")
: `${formatDecimal(vm.metrics?.couponYieldPercent)} %`;
let yieldContent;
if (vm.metrics?.effectiveYield) {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt),
date: formatDate(vm.metrics?.effectiveYield.date),
price: formatDecimal(
vm.metrics?.effectiveYield.pricePercent,
),
target: t(
`bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
);
}
else {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
);
}
return (
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
{!!vm.metrics && (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric label={t("bond.creditRating")} value={rating} />
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric label={t("bond.couponDate")} value={couponDate} />
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
<Metric label={t("bond.couponYield")} value={couponYield} />
</div>
{!!vm.metrics.creditRating && (
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
rel="noopener noreferrer"
size="sm"
target="_blank"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text c="dimmed" size="xs">
{t("bond.ratingChecked", {
date: formatSnapshotTime(vm.metrics.creditRating.observedAt),
})}
</Text>
</Group>
)}
<Text c="dimmed" size="xs">
{t("bond.ratingCoverage")}
</Text>
{yieldContent}
<Text c="dimmed" size="xs">
{t("bond.couponYieldNote")}
</Text>
{!!vm.metrics?.couponYieldPriceAt && (
<Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt),
})}
</Text>
)}
{vm.metrics.unavailable.length > 0 && (
<Text c="dimmed" size="xs">
{t("bond.metricsPartial")}
</Text>
)}
</Stack>
)}
</InstrumentSection>
);
};
const BondCoupons: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<InstrumentSection
state={vm.couponsState}
title={t("bond.upcomingCoupons")}
>
{!!vm.coupons && (
<Stack gap="md">
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
)}
{vm.upcoming.length > 0
? (
vm.upcoming.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
<Text c="dimmed" size="xs">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNote")}
</Text>
{!!vm.coupons.positionAt && (
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
)}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length > 0
? (
vm.pastCoupons.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
)}
</InstrumentSection>
);
};
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = props => (
<div>
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
{props.value}
</Text>
</div>
);
type CouponProps = {
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
};
const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{!!coupon.estimatedAmount && (
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
)}
</Group>
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != undefined && (
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
)}
{!!coupon.recordDate && (
<Text c="dimmed" size="xs">
{t("stock.record")}: {formatDate(coupon.recordDate)}
</Text>
)}
</Group>
</Paper>
);
};
export { BondPageView };
+7 -11
View File
@@ -1,7 +1,5 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type BondPeriod,
useBond,
@@ -11,10 +9,8 @@ import {
useBondPosition,
useBondQuote,
} from "@/entities/bond";
import {
section,
useInstrumentOperations,
} from "@/features/instrument-details";
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
import { section } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
@@ -51,12 +47,12 @@ function useBondPageVm() {
const position = useBondPosition(uid);
const coupons = useBondCoupons(uid);
const history = useInstrumentOperations(
uid,
filter,
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
categories[filter],
);
categories: categories[filter],
instrumentUid: uid,
limit: 20,
});
const { operations } = history;
@@ -0,0 +1,152 @@
import type { FC } from "react";
import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BondCoupons as Coupons } from "@/entities/bond";
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import { Metric } from "./Metric.view";
type BondCouponsProps = {
coupons?: Coupons;
pastCoupons: Coupons["coupons"];
state: SectionState;
upcoming: Coupons["coupons"];
};
const BondCoupons: FC<BondCouponsProps> = (props) => {
const { coupons, pastCoupons, state, upcoming } = props;
const { t } = useTranslation();
return (
<InstrumentSection
state={state}
title={t("bond.upcomingCoupons")}
>
{!!coupons && (
<Stack gap="md">
{!!(coupons.estimateUnavailable || coupons.positionIsStale) && (
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
)}
{upcoming.length > 0
? (
upcoming.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
<Text c="dimmed" size="xs">
{t("bond.couponWindow", {
from: formatDate(coupons.from),
to: formatDate(coupons.to),
})}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNote")}
</Text>
{!!coupons.positionAt && (
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(coupons.positionAt),
})}
</Text>
)}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{pastCoupons.length > 0
? (
pastCoupons.map(coupon => (
<Coupon
coupon={coupon}
key={`${coupon.number}:${coupon.paymentDate}`}
/>
))
)
: <Text c="dimmed">{t("bond.noCoupons")}</Text>}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
)}
</InstrumentSection>
);
};
type CouponProps = {
coupon: Coupons["coupons"][number];
};
const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper p="md" radius="md" withBorder>
<Group align="start" justify="space-between">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text c="dimmed" size="xs">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{!!coupon.estimatedAmount && (
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
)}
</Group>
<Group gap="md" mt="sm">
<Badge color="gray" variant="light">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != undefined && (
<Text c="dimmed" size="xs">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
)}
{!!coupon.recordDate && (
<Text c="dimmed" size="xs">
{t("stock.record")}: {formatDate(coupon.recordDate)}
</Text>
)}
</Group>
</Paper>
);
};
export { BondCoupons };
@@ -0,0 +1,150 @@
import type { FC } from "react";
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BondMetrics as Metrics } from "@/entities/bond";
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../BondPage.module.css";
import { Metric } from "./Metric.view";
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
const BondMetrics: FC<BondMetricsProps> = (props) => {
const { metrics, state } = props;
const { t } = useTranslation();
const creditRating = metrics?.creditRating;
let rating = t(
metrics?.unavailable.includes("rating")
? "bond.ratingUnavailable"
: "bond.ratingMissing",
);
if (creditRating) {
rating
= creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (creditRating.value ?? t("bond.unknown"));
}
const couponDate = metrics?.couponDate
? formatDate(metrics?.couponDate)
: t("bond.unknown");
const effectiveYield = metrics?.effectiveYield
? `${formatDecimal(metrics?.effectiveYield.value)} %`
: t("bond.unknown");
const couponYield
= metrics?.couponYieldPercent == undefined
? t("bond.unknown")
: `${formatDecimal(metrics?.couponYieldPercent)} %`;
let yieldContent;
if (metrics?.effectiveYield) {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldSource", {
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt),
date: formatDate(metrics?.effectiveYield.date),
price: formatDecimal(
metrics?.effectiveYield.pricePercent,
),
target: t(
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
})}
</Text>
);
}
else {
yieldContent = (
<Text c="dimmed" size="xs">
{t("bond.yieldUnavailable")}
</Text>
);
}
return (
<InstrumentSection state={state} title={t("bond.metrics")}>
{!!metrics && (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric label={t("bond.creditRating")} value={rating} />
<Metric
label={t("bond.couponAmount")}
value={formatMoney(metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric label={t("bond.couponDate")} value={couponDate} />
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
<Metric label={t("bond.couponYield")} value={couponYield} />
</div>
{!!metrics.creditRating && (
<Group gap="xs">
<Anchor
href={metrics.creditRating.sourceUrl}
rel="noopener noreferrer"
size="sm"
target="_blank"
>
{t("bond.ratingSource", {
date: formatDate(metrics.creditRating.date),
})}
</Anchor>
<Text c="dimmed" size="xs">
{t("bond.ratingChecked", {
date: formatSnapshotTime(metrics.creditRating.observedAt),
})}
</Text>
</Group>
)}
<Text c="dimmed" size="xs">
{t("bond.ratingCoverage")}
</Text>
{yieldContent}
<Text c="dimmed" size="xs">
{t("bond.couponYieldNote")}
</Text>
{!!metrics?.couponYieldPriceAt && (
<Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(metrics?.couponYieldPriceAt),
})}
</Text>
)}
{metrics.unavailable.length > 0 && (
<Text c="dimmed" size="xs">
{t("bond.metricsPartial")}
</Text>
)}
</Stack>
)}
</InstrumentSection>
);
};
export { BondMetrics };
@@ -0,0 +1,19 @@
import type { FC } from "react";
import { Text } from "@mantine/core";
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = props => (
<div>
<Text c="dimmed" size="xs">
{props.label}
</Text>
<Text fw={600} size="lg">
{props.value}
</Text>
</div>
);
export { Metric };
+6 -8
View File
@@ -1,7 +1,6 @@
import { useParams, useSearchParams } from "react-router";
import type { OperationCategory } from "@/entities/broker-operation";
import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
@@ -13,7 +12,6 @@ import {
} from "@/entities/stock";
import {
section,
useInstrumentOperations,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
@@ -51,12 +49,12 @@ function useStockPageVm() {
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const history = useInstrumentOperations(
uid,
filter,
const history = useBrokerageOperationHistory(uid ? "all" : undefined, {
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
categories[filter],
);
categories: categories[filter],
instrumentUid: uid,
limit: 20,
});
const { operations } = history;
+1
View File
@@ -13,6 +13,7 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
export {
invalidateResourceAccess,
type InvestmentResourceMeta,
unwrapResourceResult,
useResourceQuery,
} from "./resource-query";
+9 -2
View File
@@ -16,9 +16,11 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
type Resource = {
data: unknown;
meta: components["schemas"]["InvestmentResourceMetaDto"];
meta: InvestmentResourceMeta;
};
function invalidateResourceAccess(
@@ -183,4 +185,9 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
};
}
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
export {
invalidateResourceAccess,
type InvestmentResourceMeta,
unwrapResourceResult,
useResourceQuery,
};
-1
View File
@@ -8,4 +8,3 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export { useImageFallback } from "./useImageFallback";
-10
View File
@@ -1,10 +0,0 @@
import { useState } from "react";
function useImageFallback() {
const [failed, setFailed] = useState(false);
const onError = () => setFailed(true);
return { failed, onError };
}
export { useImageFallback };