{card.bankName || t("bank.card")}
+
{card.archivedAt && (
{t("bank.archived")}
)}
+
-
+
+
{`•••• •••• •••• ${card.lastFour || "••••"}`}
+
{card.name}
+
{formatMoney(card.balance)}
+
{card.paymentSystem && (
{card.paymentSystem === "mastercard" && (
)}
+
{t(`bank.paymentSystems.${card.paymentSystem}`)}
)}
@@ -66,6 +62,7 @@ function bankTone(bank: string) {
if (/сбер|sber/.test(bank)) return "green";
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
if (/альфа|alfa/.test(bank)) return "red";
+
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
}
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts
new file mode 100644
index 0000000..2823136
--- /dev/null
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts
@@ -0,0 +1,76 @@
+import type { ComponentProps } from "react";
+
+import { DateInput } from "@mantine/dates";
+import { useController, useFormContext } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+
+import { bankDateInputValue } from "../../model/form-validation";
+import { type BankCardValues, bankDateSchema } from "../../model/form.schema";
+
+function useBankCardFieldsVm() {
+ const {
+ control,
+ formState: { errors },
+ register,
+ } = useFormContext
();
+
+ const { t } = useTranslation();
+
+ const { field: paymentSystemField } = useController({
+ control,
+ name: "paymentSystem",
+ });
+
+ const { field: openingDateField } = useController({
+ control,
+ name: "openingDate",
+ });
+
+ const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
+ label: t(`bank.paymentSystems.${value}`),
+ value,
+ }));
+
+ const onPaymentSystemChange: (value: null | string) => void = value =>
+ paymentSystemField.onChange(value ?? "");
+
+ const dateParser: ComponentProps["dateParser"] = value =>
+ bankDateSchema.safeParse(bankDateInputValue(value)).success
+ ? bankDateInputValue(value)
+ : null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled openingDateField as null.
+
+ const onOpeningDateBlur: ComponentProps["onBlur"] = (
+ event,
+ ) => {
+ openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
+ openingDateField.onBlur();
+ };
+
+ const onOpeningDateChange: (value: null | string) => void = value =>
+ openingDateField.onChange(value ?? "");
+
+ const onOpeningDateInput: ComponentProps["onInput"] = event =>
+ openingDateField.onChange(bankDateInputValue(event.currentTarget.value));
+
+ const openingDateValue = bankDateSchema.safeParse(openingDateField.value)
+ .success
+ ? openingDateField.value
+ : undefined;
+
+ return {
+ dateParser,
+ errors,
+ onOpeningDateBlur,
+ onOpeningDateChange,
+ onOpeningDateInput,
+ onPaymentSystemChange,
+ openingDateField,
+ openingDateValue,
+ paymentSystemField,
+ paymentSystems,
+ register,
+ t,
+ };
+}
+
+export { useBankCardFieldsVm };
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
index ff12e6a..3bc5e06 100644
--- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
-import type { BankCardValues } from "../../model/form-validation";
+import type { BankCardValues } from "../../model/form.schema";
import { BankCardFields } from "./BankCardForm.view";
@@ -20,6 +20,7 @@ const meta = {
openingDate: "2026-08-01",
},
});
+
return (
@@ -38,8 +39,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/BankCards/CardFields",
} satisfies Meta;
+
export default meta;
+
type Story = StoryObj;
+
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
args: { archived: true },
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
index 5692bf3..6ced133 100644
--- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
@@ -12,8 +12,10 @@ import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
-const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
+/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+const card: BankCard
+ = {
archivedAt: null,
balance: { currency: "RUB", value: "50" },
bankName: "Банк",
@@ -24,8 +26,9 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
- }
-/* eslint-enable unicorn/no-null */);
+ };
+
+/* eslint-enable unicorn/no-null */
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
@@ -41,8 +44,10 @@ function setup(value: BankCard) {
,
);
+
return close;
}
+
describe("BankCardForm", () => {
it("waits for the exit transition before notifying the parent", async () => {
const close = setup(card);
@@ -64,20 +69,23 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
+
return HttpResponse.json(card);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
- expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ expect(body).toEqual(
{
bankName: null,
lastFour: null,
name: "Основная",
paymentSystem: null,
version: 1,
- }
- /* eslint-enable unicorn/no-null */));
+ },
+ /* eslint-enable unicorn/no-null */
+ );
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
@@ -92,6 +100,7 @@ describe("BankCardForm", () => {
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
+
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
@@ -132,6 +141,7 @@ describe("BankCardForm", () => {
const body = (await request.json()) as Record;
bodies.push(body);
version = 2;
+
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -146,9 +156,11 @@ describe("BankCardForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
+
const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
+
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
index a7e30db..493dc8d 100644
--- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
@@ -1,121 +1,61 @@
-import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
-import { type FC, useState } from "react";
-import { FormProvider, useForm } from "react-hook-form";
-import { useTranslation } from "react-i18next";
+import { type FC } from "react";
+import { FormProvider } from "react-hook-form";
import { formatDate } from "@/shared/lib";
-import { useBankMutations } from "../../api/mutations";
-import { type BankCard, useBankCard } from "../../api/queries";
-import {
- bankCardSchema,
- type BankCardValues,
- bankMoneyValue,
- bankToday,
-} from "../../model/form-validation";
+import { type BankCard } from "../../api/queries";
import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
-import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view";
+import { useBankCardFormVm } from "./BankCardForm.vm";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
+
const BankCardForm: FC = (props) => {
- useBankFormFocus();
- const [opened, setOpened] = useState(true);
- const { t } = useTranslation();
- const [intent] = useState(() => crypto.randomUUID());
- const [version, setVersion] = useState(props.card?.version ?? 1);
- const [error, setError] = useState();
- const [latest, setLatest] = useState();
- const fresh = useBankCard(props.card?.id);
- const mutation = useBankMutations();
- const methods = useForm({
- defaultValues: {
- bankName: props.card?.bankName ?? "",
- lastFour: props.card?.lastFour ?? "",
- name: props.card?.name ?? "",
- openingBalance: props.card?.openingBalance.value ?? "0",
- openingDate: props.card?.openingDate ?? bankToday(),
- paymentSystem: props.card?.paymentSystem ?? "",
- },
- resolver: zodResolver(bankCardSchema),
- });
- const isPending
- = !opened
- || methods.formState.isSubmitting
- || (fresh.isFetching && Boolean(error));
+ const {
+ dialogTitle,
+ error,
+ isPending,
+ latest,
+ methods,
+ onAdoptVersion,
+ onCancel,
+ onReloadRecord,
+ onRequestClose,
+ onSubmit,
+ opened,
+ t,
+ } = useBankCardFormVm(props);
+
return (
!isPending && setOpened(false)}
+ onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
- title={t(props.card ? "bank.editCard" : "bank.addCard")}
+ title={dialogTitle}
>
-
- )}
- large
- state={vm.candlesState}
- title={t("stock.chart")}
- >
- {!!(vm.candles) && ((
- vm.chartData.length > 0
- ? (
- <>
-
- new Intl.NumberFormat("ru-RU", {
- maximumFractionDigits: 4,
- }).format(value)}
- withDots={vm.chartData.length === 1}
- withGradient
- yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
- />
-
- {t(
- props.priceUnit === "percent"
- ? "bond.chartNote"
- : "stock.chartNote",
- )}
-
- {!!(vm.hasIncompleteCandle) && ((
-
- {t("stock.incomplete")}
-
- ))}
- {vm.averageLine !== null && ((
-
- {t("stock.averageLine")}
- :
- {" "}
- {formatMoney(vm.position?.averagePurchasePrice)}
-
- ))}
-
-
-
- {t("stock.priceTable")}
-
- {t("stock.priceTableHint", { count: vm.chartData.length })}
-
-
-
-
-
-
-
- {t("stock.date")}
- {t("stock.price")}
- {t("stock.candle")}
-
-
-
- {vm.priceRows.map(candle => (
-
- {candle.date}
-
- {priceLabel(candle.close)}
-
-
-
- {t(
- candle.isComplete
- ? "stock.complete"
- : "stock.forming",
- )}
-
-
-
- ))}
-
-
-
-
-
-
- >
- )
- : (
-
- {t(
- vm.candlesState.partial
- ? "investment.partial"
- : "stock.noCandles",
- )}
-
- )
- ))}
-
- );
-};
-
-export { InstrumentChart };
diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx
new file mode 100644
index 0000000..7aa826c
--- /dev/null
+++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.view.tsx
@@ -0,0 +1,195 @@
+import type { FC } from "react";
+
+import { AreaChart } from "@mantine/charts";
+import {
+ Accordion,
+ Badge,
+ ScrollArea,
+ SegmentedControl,
+ Table,
+ Text,
+} from "@mantine/core";
+
+import { formatMoney } from "@/shared/lib";
+
+import type { InstrumentChartVm } from "../../model/types";
+
+import classes from "../InstrumentDetails.module.css";
+import { InstrumentSection } from "../InstrumentSection.view";
+import { useInstrumentChartVm } from "./InstrumentChart.vm";
+
+type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
+
+const InstrumentChart: FC = (props) => {
+ const {
+ chartLabel,
+ formatChartValue,
+ periodOptions,
+ priceRows,
+ referenceLines,
+ t,
+ vm,
+ } = useInstrumentChartVm(props);
+
+ let chartContent;
+
+ if (vm.candles) {
+ if (vm.chartData.length > 0) {
+ chartContent = (
+ <>
+
+
+
+ {t(
+ props.priceUnit === "percent"
+ ? "bond.chartNote"
+ : "stock.chartNote",
+ )}
+
+
+ {!!vm.hasIncompleteCandle && (
+
+ {t("stock.incomplete")}
+
+ )}
+
+ {vm.averageLine !== null && (
+
+ {t("stock.averageLine")}:{" "}
+ {formatMoney(vm.position?.averagePurchasePrice)}
+
+ )}
+
+
+
+
+ {t("stock.priceTable")}
+
+
+ {t("stock.priceTableHint", { count: vm.chartData.length })}
+
+
+
+
+
+
+
+
+ {t("stock.date")}
+
+ {t("stock.price")}
+
+ {t("stock.candle")}
+
+
+
+
+ {priceRows.map((candle) => {
+ const displayColor = candle.isComplete
+ ? "gray"
+ : "blue";
+
+ return (
+
+ {candle.date}
+
+
+ {candle.price}
+
+
+
+
+ {t(
+ candle.isComplete
+ ? "stock.complete"
+ : "stock.forming",
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ >
+ );
+ }
+ else {
+ chartContent = (
+
+ {t(
+ vm.candlesState.partial
+ ? "investment.partial"
+ : "stock.noCandles",
+ )}
+
+ );
+ }
+ }
+
+ return (
+
+
+
+ )}
+ large
+ state={vm.candlesState}
+ title={t("stock.chart")}
+ >
+ {chartContent}
+
+ );
+};
+
+export { InstrumentChart };
diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts
new file mode 100644
index 0000000..02c6d0c
--- /dev/null
+++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts
@@ -0,0 +1,64 @@
+import { useTranslation } from "react-i18next";
+
+import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib";
+
+import type { InstrumentChartVm } from "../../model/types";
+
+type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
+
+function useInstrumentChartVm(props: InstrumentChartProps) {
+ const { vm } = props;
+ const { t } = useTranslation();
+
+ const priceRows = vm.priceRows.map((candle) => {
+ const currency = vm.candles?.currency;
+ const value = candle.close;
+
+ const amount = currency ? { currency, value } : undefined;
+
+ const price = props.priceUnit === "percent"
+ ? `${formatDecimal(value)} %`
+ : formatMoney(amount);
+
+ return { ...candle, price };
+ });
+
+ const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({
+ label: t(`stock.periods.${value}`),
+ value,
+ }));
+
+ const chartLabel = t(
+ props.priceUnit === "percent" ? "bond.chartLabel" : "stock.chartLabel",
+ {
+ currency:
+ props.priceUnit === "percent"
+ ? t("bond.percentOfNominal")
+ : (vm.candles?.currency ?? ""),
+ },
+ );
+
+ const referenceLines
+ = vm.averageLine === null
+ ? []
+ : [
+ {
+ color: "orange.7",
+ ifOverflow: "extendDomain" as const,
+ label: t("stock.averageLine"),
+ y: vm.averageLine,
+ },
+ ];
+
+ return {
+ chartLabel,
+ formatChartValue,
+ periodOptions,
+ priceRows,
+ referenceLines,
+ t,
+ vm,
+ };
+}
+
+export { useInstrumentChartVm };
diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
index df9de31..6d6b75d 100644
--- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
@@ -20,25 +20,54 @@ type InstrumentEventsProps = {
const InstrumentEvents: FC
= (props) => {
const { vm } = props;
const { t } = useTranslation();
+
+ const options = [
+ "all",
+ "trades",
+ props.incomeFilter ?? "dividends",
+ "other",
+ ].map(value => ({
+ label:
+ value === (props.incomeFilter ?? "dividends") && props.incomeLabel
+ ? props.incomeLabel
+ : t(`stock.filters.${value}`),
+ value,
+ }));
+
+ let eventsContent;
+
+ if (vm.eventRows.length > 0) {
+ eventsContent = (
+
+ {vm.eventRows.map(event => (
+
+ ))}
+
+ );
+ }
+ else {
+ eventsContent = (
+
+ {t(
+ vm.operationsState.partial
+ ? "investment.partial"
+ : "stock.noOperations",
+ )}
+
+ );
+ }
+
return (
({
- label:
- value === (props.incomeFilter ?? "dividends")
- && props.incomeLabel
- ? props.incomeLabel
- : t(`stock.filters.${value}`),
- value,
- }))}
+ data={options}
onChange={vm.setFilter}
value={vm.filter}
/>
@@ -47,32 +76,14 @@ const InstrumentEvents: FC = (props) => {
state={vm.operationsState}
title={t("stock.history")}
>
- {!!(vm.operations) && ((
+ {!!vm.operations && (
<>
{props.historyNote ?? t("stock.historyNote")}
- {vm.eventRows.length > 0
- ? (
-
- {vm.eventRows.map(event => (
-
- ))}
-
- )
- : (
-
- {t(
- vm.operationsState.partial
- ? "investment.partial"
- : "stock.noOperations",
- )}
-
- )}
+
+ {eventsContent}
+
+
>
- ))}
+ )}
);
};
diff --git a/src/features/instrument-details/ui/InstrumentPosition.view.tsx b/src/features/instrument-details/ui/InstrumentPosition.view.tsx
index f4abd37..6c827ec 100644
--- a/src/features/instrument-details/ui/InstrumentPosition.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentPosition.view.tsx
@@ -16,130 +16,148 @@ type InstrumentPositionProps = {
returnNote?: string;
vm: InstrumentPositionVm;
};
+
const InstrumentPosition: FC = (props) => {
const { vm } = props;
const { t } = useTranslation();
if (vm.position && vm.position.accounts.length === 0) return;
+
+ const positionReturnColor = vm.position?.absoluteReturn?.value.startsWith("-")
+ ? "red.8"
+ : "green.8";
+
return (
- {!!(vm.position) && ((
+ {!!vm.position && (
- {!!(vm.position.isStale) && ((
+ {!!vm.position.isStale && (
{t("portfolio.staleWarning")}
- ))}
+ )}
+
{props.quantityLabel ?? t("stock.quantity")}
+
{formatDecimal(vm.position.quantity)}
+
{t("stock.value")}
+
{formatMoney(vm.position.currentValue)}
+
{t("stock.average")}
+
{formatMoney(vm.position.averagePurchasePrice)}
+
{t("stock.openReturn")}
-
+
+
{formatMoney(vm.position.absoluteReturn)}
- {vm.position.returnPercent !== null && ((
+
+ {vm.position.returnPercent !== null && (
- {formatDecimal(vm.position.returnPercent)}
- {" "}
- %
+ {formatDecimal(vm.position.returnPercent)} %
- ))}
+ )}
+
{props.returnNote ?? t("stock.openReturnNote")}
+
- {vm.position.accounts.map(account => (
-
- {account.account
- ? (
-
- {account.account.name}
-
- )
- : (
-
{t("stock.portfolio")}
- )}
-
-
- {props.quantityLabel ?? t("stock.quantity")}
-
- {formatDecimal(account.quantity)}
-
-
-
- {t("stock.average")}
-
- {formatMoney(account.averagePurchasePrice)}
-
-
-
- {t("stock.value")}
-
- {formatMoney(account.currentValue)}
-
-
-
- {t("stock.openReturn")}
-
-
- {formatMoney(account.absoluteReturn)}
-
- {account.returnPercent !== null && ((
-
- {formatDecimal(account.returnPercent)}
- {" "}
- %
+ {vm.position.accounts.map((account) => {
+ const accountReturnColor = account.absoluteReturn?.value.startsWith("-")
+ ? "red.8"
+ : undefined;
+
+ return (
+
+ {account.account
+ ? (
+
+ {account.account.name}
+
+ )
+ : (
+
{t("stock.portfolio")}
+ )}
+
+
+
+ {props.quantityLabel ?? t("stock.quantity")}
- ))}
+
+ {formatDecimal(account.quantity)}
+
+
+
+
+ {t("stock.average")}
+
+
+ {formatMoney(account.averagePurchasePrice)}
+
+
+
+
+ {t("stock.value")}
+
+
+ {formatMoney(account.currentValue)}
+
+
+
+
+ {t("stock.openReturn")}
+
+
+
+ {formatMoney(account.absoluteReturn)}
+
+
+ {account.returnPercent !== null && (
+
+ {formatDecimal(account.returnPercent)} %
+
+ )}
+
-
- ))}
+ );
+ })}
+
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.position.observedAt),
})}
- ))}
+ )}
);
};
diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx
index e021b77..9f3e9d7 100644
--- a/src/features/instrument-details/ui/InstrumentSection.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx
@@ -29,12 +29,46 @@ type InstrumentSectionProps = PropsWithChildren<{
state: SectionState;
title: string;
}>;
+
const InstrumentSection: FC
= (props) => {
const { t } = useTranslation();
- const readError = t(props.state.hasData ? "accounts.refreshError" : "stock.sectionError");
+
+ const readError = t(
+ props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
+ );
+
+ const sectionClassName = props.embedded
+ ? classes.embeddedSection
+ : classes.section;
+
+ const skeletonHeight = props.large
+ ? CHART_SKELETON_HEIGHT_PX
+ : SECTION_SKELETON_HEIGHT_PX;
+
+ let sectionContent;
+
+ if (props.state.loading) {
+ sectionContent = (
+
+
+
+
+
+
+
+ );
+ }
+ else {
+ sectionContent = (
+ props.children
+ );
+ }
+
return (
@@ -42,34 +76,37 @@ const InstrumentSection: FC = (props) => {
{props.title}
+
{props.controls}
- {!!(props.state.stale) && ((
- {t("investment.stale")}
- ))}
- {!!(props.state.partial) && ((
+
+ {!!props.state.stale && {t("investment.stale")}}
+
+ {!!props.state.partial && (
{t("investment.partial")}
- ))}
- {!!(props.state.lastSuccessfulAt) && ((
+ )}
+
+ {!!props.state.lastSuccessfulAt && (
{t("investment.sourceTime", {
date: formatSnapshotTime(props.state.lastSuccessfulAt),
})}
- ))}
- {!!(props.state.refreshing) && ((
+ )}
+
+ {!!props.state.refreshing && (
{t("accounts.refreshing")}
- ))}
- {!!(props.state.error) && ((
+ )}
+
+ {!!props.state.error && (
- {props.state.sourceError
- ? t("investment.syncFailed")
- : readError}
+ {props.state.sourceError ? t("investment.syncFailed") : readError}
+
- ))}
- {props.state.loading
- ? (
-
-
-
-
-
-
- )
- : (
- props.children
- )}
+ )}
+
+ {sectionContent}
);
};
diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts
index 5e23015..a25a7fe 100644
--- a/src/features/tbank-connection/model/connection-boundary.ts
+++ b/src/features/tbank-connection/model/connection-boundary.ts
@@ -31,6 +31,7 @@ function commitTbankConnectionBoundary(
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
const [method, path] = queryKey;
+
return (
method === "get"
&& typeof path === "string"
diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx
index fec1d07..d1a223a 100644
--- a/src/features/tbank-connection/model/screen-sync.test.tsx
+++ b/src/features/tbank-connection/model/screen-sync.test.tsx
@@ -19,11 +19,14 @@ function auth() {
refresh: () => Promise.resolve(true),
});
}
+
describe("page-owned screen synchronization", () => {
it("ignores a late sync acceptance after the account scope changes", async () => {
auth();
- const { promise: blocked, resolve: release } = Promise.withResolvers();
+ const { promise: blocked, resolve: release }
+ = Promise.withResolvers();
+
const posted: string[] = [];
const polled: string[] = [];
server.use(
@@ -32,8 +35,10 @@ describe("page-owned screen synchronization", () => {
accountId: string;
screen: "account";
};
+
posted.push(scope.accountId);
await blocked;
+
return HttpResponse.json(
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
{ status: 202 },
@@ -41,6 +46,7 @@ describe("page-owned screen synchronization", () => {
}),
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
polled.push(String(params.id));
+
return HttpResponse.json({
id: params.id,
resources: [],
@@ -48,6 +54,7 @@ describe("page-owned screen synchronization", () => {
});
}),
);
+
const { rerender, result } = renderHook(
({ accountId }) => useScreenSync({ accountId, screen: "account" }, []),
{
@@ -55,6 +62,7 @@ describe("page-owned screen synchronization", () => {
wrapper: createQueryWrapper().wrapper,
},
);
+
let pending!: Promise;
act(() => {
pending = result.current.refresh();
@@ -79,6 +87,7 @@ describe("page-owned screen synchronization", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
+
return isUnavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
@@ -87,14 +96,17 @@ describe("page-owned screen synchronization", () => {
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
+
return new HttpResponse(null, { status: 500 });
}),
);
const { queryClient, wrapper } = createQueryWrapper();
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
+
return {
accounts,
disabled,
@@ -103,6 +115,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper },
);
+
if (cached) {
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -111,6 +124,7 @@ describe("page-owned screen synchronization", () => {
isUnavailable = true;
await act(() => result.current.accounts.refetch());
}
+
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
isUnavailable = false;
await act(() => result.current.sync.retry());
@@ -119,7 +133,14 @@ describe("page-owned screen synchronization", () => {
cached ? "3" : "2",
);
expect(modes).toEqual(
- cached ? [null, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, "observe"] : [null, "observe"],
+ cached
+ ? [
+ null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ "observe",
+ ]
+ : [null, "observe"], // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
@@ -141,7 +162,9 @@ describe("page-owned screen synchronization", () => {
async (boundary, pending) => {
auth();
- const { promise: blocked, resolve: release } = Promise.withResolvers();
+ const { promise: blocked, resolve: release }
+ = Promise.withResolvers();
+
let isRecovering = false;
let isChanged = false;
server.use(
@@ -154,18 +177,22 @@ describe("page-owned screen synchronization", () => {
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
isRecovering = true;
await blocked;
+
return HttpResponse.json(resource([], { stateVersion: "old" }));
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: unknown[] = [];
+
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
@@ -173,6 +200,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper },
);
+
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise;
act(() => {
@@ -182,6 +210,7 @@ describe("page-owned screen synchronization", () => {
if (pending) await waitFor(() => expect(isRecovering).toBe(true));
await act(async () => {
isChanged = true;
+
switch (boundary) {
case "access": {
invalidateResourceAccess(queryClient, "brokerage", {
@@ -190,24 +219,29 @@ describe("page-owned screen synchronization", () => {
break;
}
+
case "logout": {
configureAuthClient();
queryClient.clear();
break;
}
+
case "session": {
auth();
queryClient.clear();
break;
}
- case "connection": { commitTbankConnectionBoundary(queryClient, {
- connected: true,
- connectedAt: "2026-09-27T00:00:00Z",
- });
+
+ case "connection": {
+ commitTbankConnectionBoundary(queryClient, {
+ connected: true,
+ connectedAt: "2026-09-27T00:00:00Z",
+ });
}
}
+
release();
await recovery;
});
@@ -233,6 +267,7 @@ describe("page-owned screen synchronization", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
+
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
@@ -242,6 +277,7 @@ describe("page-owned screen synchronization", () => {
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
+
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
@@ -252,10 +288,12 @@ describe("page-owned screen synchronization", () => {
}),
);
const { queryClient, wrapper } = createQueryWrapper();
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
@@ -263,9 +301,11 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper },
);
+
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
+
if (retryStatus) {
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -273,6 +313,7 @@ describe("page-owned screen synchronization", () => {
);
await act(() => result.current.sync.retry());
}
+
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
@@ -290,16 +331,20 @@ describe("page-owned screen synchronization", () => {
const bodies: unknown[] = [];
let isCompleted = false;
let polls = 0;
+
const meta = () =>
resource([], { stateVersion: isCompleted ? "2" : "1" }).meta;
+
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
+
return HttpResponse.json(resource([], meta()));
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
bodies.push(await request.json());
isCompleted = true;
+
return HttpResponse.json(
{
id: "screen",
@@ -311,7 +356,10 @@ describe("page-owned screen synchronization", () => {
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
- return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ return HttpResponse.json(
{
errorCode: null,
id: "screen",
@@ -320,13 +368,16 @@ describe("page-owned screen synchronization", () => {
resources: [meta()],
scope: { screen: "accounts" },
state: "succeeded",
- }
- /* eslint-enable unicorn/no-null */));
+ },
+ /* eslint-enable unicorn/no-null */
+ );
}),
);
+
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
@@ -334,6 +385,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper: createQueryWrapper().wrapper },
);
+
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => {
await Promise.all([
@@ -348,6 +400,7 @@ describe("page-owned screen synchronization", () => {
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
vi.useFakeTimers();
+
try {
await act(() => vi.advanceTimersByTimeAsync(120_000));
expect(polls).toBe(1);
@@ -365,26 +418,36 @@ describe("page-owned screen synchronization", () => {
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] });
+
try {
auth();
let isCompleted = false;
let polls = 0;
const modes: (null | string)[] = [];
+
const snapshot = () =>
- resource(isCompleted ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ resource(
+ isCompleted
+ ? []
+ : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable API fixture.
{
stateVersion: isCompleted ? "covered" : "initial",
- sync: { errorCode: null, id: "history", state: "running" },
- }
- /* eslint-enable unicorn/no-null */));
+ sync: { errorCode: null, id: "history", state: "running" }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
+ },
+ );
+
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
+
return HttpResponse.json(snapshot());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
- return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ return HttpResponse.json(
{
errorCode: null,
id: "history",
@@ -397,13 +460,16 @@ describe("page-owned screen synchronization", () => {
resources: [snapshot().meta],
scope: null,
state: "running",
- }
- /* eslint-enable unicorn/no-null */));
+ },
+ /* eslint-enable unicorn/no-null */
+ );
}),
);
+
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
@@ -411,6 +477,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper: createQueryWrapper().wrapper },
);
+
await waitFor(() =>
expect(result.current.accounts.meta?.sync.id).toBe("history"),
);
@@ -447,20 +514,25 @@ describe("page-owned screen synchronization", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
- resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ resource(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ [],
{
sync: { errorCode: null, id: "revoked", state: "running" },
- }
- /* eslint-enable unicorn/no-null */)),
+ },
+ /* eslint-enable unicorn/no-null */
+ ),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
);
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
@@ -468,6 +540,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper: createQueryWrapper().wrapper },
);
+
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.data).toBeUndefined();
});
@@ -475,7 +548,10 @@ describe("page-owned screen synchronization", () => {
auth();
let isObserved = false;
const modes: (null | string)[] = [];
- const dependency = resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ const dependency = resource(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ [],
{
resourceKey: "operations",
stateVersion: isObserved ? "covered" : "initial",
@@ -484,24 +560,36 @@ describe("page-owned screen synchronization", () => {
id: "dependency",
state: isObserved ? "idle" : "running",
},
- }
- /* eslint-enable unicorn/no-null */)).meta;
+ },
+ /* eslint-enable unicorn/no-null */
+ ).meta;
+
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) isObserved = true;
+
return HttpResponse.json(
- resource(isObserved ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, {
- dependencies: [
- { ...dependency, stateVersion: isObserved ? "covered" : "initial" },
- ],
- resourceKey: "projection",
- }),
+ resource(
+ isObserved
+ ? []
+ : null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ {
+ dependencies: [
+ {
+ ...dependency,
+ stateVersion: isObserved ? "covered" : "initial",
+ },
+ ],
+ resourceKey: "projection",
+ },
+ ),
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
- HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ HttpResponse.json(
{
errorCode: null,
id: "dependency",
@@ -510,18 +598,22 @@ describe("page-owned screen synchronization", () => {
resources: [{ ...dependency, stateVersion: "covered" }],
scope: null,
state: "succeeded",
- }
- /* eslint-enable unicorn/no-null */)),
+ },
+ /* eslint-enable unicorn/no-null */
+ ),
),
);
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
+
return accounts;
},
{ wrapper: createQueryWrapper().wrapper },
);
+
await waitFor(() => expect(result.current.data).toEqual([]));
// eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response.
expect(modes).toEqual([null, "observe"]);
@@ -536,9 +628,11 @@ describe("page-owned screen synchronization", () => {
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
+
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
+
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
@@ -546,6 +640,7 @@ describe("page-owned screen synchronization", () => {
},
{ wrapper: createQueryWrapper().wrapper },
);
+
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => result.current.sync.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts
index 6bfd0a2..e6a26fa 100644
--- a/src/features/tbank-connection/model/screen-sync.ts
+++ b/src/features/tbank-connection/model/screen-sync.ts
@@ -15,15 +15,21 @@ 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;
observe: (version?: string) => Promise;
queryKey: QueryKey;
transport: {
- fetchStatus: "fetching" | "idle" | "paused";
- status: "error" | "pending" | "success";
+ fetchStatus: "fetching"
+ | "idle"
+ | "paused";
+ status: "error"
+ | "pending"
+ | "success";
};
};
+
const STATUS_INTERVAL_MS = 1000;
function observeResource(
@@ -38,27 +44,34 @@ function observeResource(
if (observed.get(key) === completedId) return;
observed.set(key, completedId);
void resource.observe().catch(() => {});
+
return;
}
+
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
+
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
});
+
if (changed.length === 0) return;
+
const notification = JSON.stringify(
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
);
- if (observed.get(resource.meta.resourceKey) === notification)
- return;
+
+ if (observed.get(resource.meta.resourceKey) === notification) return;
observed.set(resource.meta.resourceKey, notification);
+
const own = changed.find(
meta => meta.resourceKey === resource.meta?.resourceKey,
);
+
void resource.observe(own?.stateVersion).catch(() => {});
}
@@ -67,17 +80,24 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const client = useQueryClient();
const deniedRef = useRef(new Set());
const scopeKey = JSON.stringify(scope);
- const [requested, setRequested] = useState(null);
+
+ const [requested, setRequested] = useState(null);
+
const requestInFlightRef = useRef(null);
const currentScopeRef = useRef(scopeKey);
useLayoutEffect(() => {
currentScopeRef.current = scopeKey;
}, [scopeKey]);
- const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
+
+ const isCurrentMutation
+ = JSON.stringify(mutation.variables?.body) === scopeKey;
+
const observedRef = useRef(new Map());
+
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
@@ -93,6 +113,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
),
]),
];
+
const statuses = useAuthenticatedQueries(
ids.map(id => ({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
@@ -101,12 +122,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refetchInterval: (query: {
state: { data?: { state: string }; error: unknown };
}) =>
- !(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS,
+ !(
+ query.state.error
+ || (query.state.data && query.state.data.state !== "running")
+ ) && STATUS_INTERVAL_MS,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
})),
);
+
useEffect(() => {
for (const status of statuses) {
if (!status.error || deniedRef.current.has(status.error)) {
@@ -117,10 +142,16 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
invalidateResourceAccess(client, "brokerage", status.error);
}
+
const latest = new Map();
- const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt);
+
+ const orderedStatuses = statuses.toSorted(
+ (a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
+ );
+
for (const status of orderedStatuses) {
const statusResources = status.data?.resources ?? [];
+
for (const meta of statusResources) {
latest.set(meta.resourceKey, meta);
const dependencies = meta.dependencies ?? [];
@@ -128,10 +159,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
latest.set(dependency.resourceKey, dependency);
}
}
- const completed = statuses.find(status => requested?.scope === scopeKey
- && status.data?.id === requested.id && status.data.state !== "running")?.data;
- for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current);
+
+ const completed = statuses.find(
+ status =>
+ requested?.scope === scopeKey
+ && status.data?.id === requested.id
+ && status.data.state !== "running",
+ )?.data;
+
+ for (const resource of resources)
+ observeResource(resource, latest, completed?.id, observedRef.current);
}, [client, statuses, resources, requested, scopeKey]);
+
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
@@ -139,15 +178,18 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
)
|| resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
+
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
);
});
+
const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return;
requestInFlightRef.current = scopeKey;
+
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
@@ -162,15 +204,20 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
*/
}
finally {
- if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ if (requestInFlightRef.current === scopeKey)
+ requestInFlightRef.current
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ = null;
}
};
+
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
resource => resource.transport.status === "error",
);
+
const failedStatuses = statuses.filter(status => status.isError);
if (failed.length > 0 || failedStatuses.length > 0) {
await Promise.allSettled([
@@ -180,6 +227,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
}
else await refresh();
};
+
return {
refresh,
refreshing: isRefreshing,
diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx
index 7f90b16..604d57d 100644
--- a/src/features/tbank-connection/model/sync.test.tsx
+++ b/src/features/tbank-connection/model/sync.test.tsx
@@ -25,25 +25,32 @@ describe("sync connection and session boundaries", () => {
refresh: () => Promise.resolve(true),
});
- const { promise: blocked, resolve: release } = Promise.withResolvers();
+ const { promise: blocked, resolve: release }
+ = Promise.withResolvers();
+
const accepted = {
id: "work",
now: "2026-09-25T12:00:00Z",
scope: { screen: "accounts" },
};
+
let started = 0;
let isChanged = false;
server.use(
http.post("*/api/v1/tbank-sync", async () => {
started += 1;
await blocked;
+
return HttpResponse.json(accepted, { status: 202 });
}),
http.get("*/api/v1/tbank-sync/:id", async () => {
const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now;
started += 1;
await blocked;
- return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ return HttpResponse.json(
{
...accepted,
errorCode: null,
@@ -51,21 +58,26 @@ describe("sync connection and session boundaries", () => {
progress: { completed: 1, discovering: false, total: 1 },
resources: [],
state: "succeeded",
- }
- /* eslint-enable unicorn/no-null */));
+ },
+ /* eslint-enable unicorn/no-null */
+ );
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: string[] = [];
+
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
- if ((event.query.queryKey as readonly unknown[])[1]
- !== "/api/v1/tbank-sync/{id}") {
+ if (
+ (event.query.queryKey as readonly unknown[])[1]
+ !== "/api/v1/tbank-sync/{id}"
+ ) {
return;
}
const data = event.query.state.data as undefined | { now?: string };
if (data?.now) published.push(data.now);
});
+
const { result } = renderHook(
() => ({
mutation: useTbankSync(),
@@ -73,39 +85,49 @@ describe("sync connection and session boundaries", () => {
}),
{ wrapper },
);
+
const screen: InvestmentScreen = { screen: "accounts" };
let mutation!: Promise;
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
+
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
+
await waitFor(() => expect(started).toBe(2));
await act(async () => {
isChanged = true;
+
if (boundary === "connection")
- commitTbankConnectionBoundary(queryClient, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ commitTbankConnectionBoundary(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ queryClient,
{
connected: false,
connectedAt: null,
- }
- /* eslint-enable unicorn/no-null */));
+ },
+ /* eslint-enable unicorn/no-null */
+ );
else {
generation += 1;
queryClient.clear();
}
+
release();
await blocked;
});
await discarded;
expect(result.current.mutation.data).toBeUndefined();
+
const key = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-sync/{id}",
{ params: { path: { id: "work" } } },
).queryKey;
+
// A new request may start after a successful connection boundary; an old result cannot win it.
if (boundary === "session")
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts
index 578b213..c53e2f6 100644
--- a/src/features/tbank-connection/model/sync.ts
+++ b/src/features/tbank-connection/model/sync.ts
@@ -6,11 +6,13 @@ import {
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
+
function useTbankSync() {
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
}
+
function useTbankSyncStatus(id: null | string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
index 8cd01eb..163c156 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
@@ -21,26 +21,31 @@ const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts",
).queryKey;
+
const portfolioQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
+
const operationsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
+
const stockQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/stocks/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
+
const bondQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bonds/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
+
const bankCardsQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards",
@@ -48,6 +53,7 @@ const bankCardsQueryKey = apiQueryClient.queryOptions(
params: { query: { includeArchived: "false" } },
},
).queryKey;
+
const unrelatedQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/auth/me",
@@ -59,6 +65,7 @@ function renderSection(client: QueryClient) {
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
+
return render(
@@ -70,21 +77,22 @@ function renderSection(client: QueryClient) {
function seededClient() {
const client = createTestQueryClient();
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
client.setQueryData(brokerageAccountsQueryKey, [
- (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
- id: "account-1",
- name: "Основной счет",
- openedOn: null,
- type: "BROKERAGE",
- }
- /* eslint-enable unicorn/no-null */),
+ {
+ id: "account-1",
+ name: "Основной счет",
+ openedOn: null,
+ type: "BROKERAGE",
+ },
+ /* eslint-enable unicorn/no-null */
]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" });
client.setQueryData(bondQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" });
client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]);
+
return client;
}
@@ -92,18 +100,23 @@ describe("TbankConnectionSection", () => {
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
- const { promise: response, resolve: finishRefetch } = Promise.withResolvers();
+ const { promise: response, resolve: finishRefetch }
+ = Promise.withResolvers();
+
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", async () => {
reads += 1;
+
if (reads === 1) {
await response;
+
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}
+
return HttpResponse.json(status);
}),
);
@@ -141,11 +154,14 @@ describe("TbankConnectionSection", () => {
server.use(
http.get("*/api/v1/tbank-connection", () => {
reads += 1;
+
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
- : HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- { connected: false, connectedAt: null }
- /* eslint-enable unicorn/no-null */));
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ : HttpResponse.json(
+ { connected: false, connectedAt: null },
+ /* eslint-enable unicorn/no-null */
+ );
}),
);
renderSection(createTestQueryClient());
@@ -244,19 +260,24 @@ describe("TbankConnectionSection", () => {
const client = seededClient();
let isAborted = false;
- const { promise: previousResponse, resolve: finishPrevious } = Promise.withResolvers<{ source: string }>();
+
+ const { promise: previousResponse, resolve: finishPrevious }
+ = Promise.withResolvers<{ source: string }>();
+
const previousRequest = client
.query({
queryFn: ({ signal }) => {
signal.addEventListener("abort", () => {
isAborted = true;
});
+
return previousResponse;
},
queryKey: operationsQueryKey,
})
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
+
renderSection(client);
await screen.findByText("Не подключено");
@@ -302,7 +323,9 @@ describe("TbankConnectionSection", () => {
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
let deletes = 0;
- const { promise: response, resolve: finishDelete } = Promise.withResolvers();
+ const { promise: response, resolve: finishDelete }
+ = Promise.withResolvers();
+
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
@@ -313,6 +336,7 @@ describe("TbankConnectionSection", () => {
http.delete("*/api/v1/tbank-connection", async () => {
deletes += 1;
if (deletes === 1) await response;
+
return deletes === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: new HttpResponse(null, { status: 204 });
@@ -324,9 +348,11 @@ describe("TbankConnectionSection", () => {
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
+
const dialog = await screen.findByRole("dialog", {
name: "Удалить подключение?",
});
+
await waitFor(() =>
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(dialog.contains(document.activeElement)).toBe(true),
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx
index 205fd1b..5d8a8ec 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx
@@ -10,7 +10,14 @@ import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => {
const viewModel = useTbankConnectionViewModel();
if (viewModel.state === "loading") return ;
- return viewModel.state === "error" ? : ;
+
+ return viewModel.state === "error"
+ ? (
+
+ )
+ : (
+
+ );
};
export { TbankConnectionSection };
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx
index 2029b30..ec68a16 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx
@@ -10,11 +10,13 @@ import {
describe("TbankConnectionSection state views", () => {
it("renders loading and retries a status error", () => {
const retry = vi.fn();
+
const { rerender } = render(
,
);
+
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
index 9fb619c..67730de 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
@@ -14,6 +14,7 @@ const TbankConnectionLoading: FC = () => {
return (
+
);
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
index aa1a587..9507c0e 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
@@ -6,9 +6,13 @@ function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(),
});
+
if (!status.data) {
- return status.isPending ? { state: "loading" as const } : { retry: () => status.refetch(), state: "error" as const };
+ return status.isPending
+ ? { state: "loading" as const }
+ : { retry: () => status.refetch(), state: "error" as const };
}
+
return {
refreshError: status.isError,
refreshing: status.isFetching,
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
index a84bf72..c16484e 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
@@ -8,19 +8,11 @@ import {
Stack,
Text,
} from "@mantine/core";
-import { useQueryClient } from "@tanstack/react-query";
-import { useEffect, useRef, useState } from "react";
-import { useForm } from "react-hook-form";
-import { useTranslation } from "react-i18next";
-import { ApiErrorCode, apiErrorCode, apiQueryClient, isApiErrorCode } from "@/shared/api";
+import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
-import {
- commitTbankConnectionBoundary,
- type TbankConnectionStatus,
-} from "../../model/connection-boundary";
-
-enum ConnectionErrorCode { RequestFailed = "REQUEST_ERROR" }
+import { type TbankConnectionStatus } from "../../model/connection-boundary";
+import { useTbankConnectionSectionLoadedVm } from "./TbankConnectionSectionLoaded.vm";
type TbankConnectionSectionLoadedProps = {
refreshError: boolean;
@@ -32,48 +24,29 @@ type TbankConnectionSectionLoadedProps = {
const TbankConnectionSectionLoaded: FC = (
props,
) => {
- const { status } = props;
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const [isDeleteOpened, setIsDeleteOpened] = useState(false);
- const removalButtonRef = useRef(null);
- const [requestError, setRequestError] = useState(null);
const {
- formState: { errors },
- handleSubmit,
+ errors,
+ isConnected,
+ isDeleteOpened,
+ isPending,
+ onCloseDisconnect,
+ onConfirmDisconnect,
+ onOpenDisconnect,
+ onSubmit,
register,
- reset,
- } = useForm<{ token: string }>();
- const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
- onError: error => setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed),
- onSuccess: (data) => {
- commitTbankConnectionBoundary(queryClient, data);
- reset();
- },
- });
- const remove = apiQueryClient.useMutation(
- "delete",
- "/api/v1/tbank-connection",
- {
- onSuccess: () => {
- commitTbankConnectionBoundary(queryClient, {
- connected: false,
- connectedAt: /* eslint-disable-line unicorn/no-null -- The disconnected API snapshot requires a null connection timestamp. */ null,
- });
- setIsDeleteOpened(false);
- },
- },
- );
- const unavailableMessage = isApiErrorCode(requestError, ApiErrorCode.TbankUnavailable) ? "tbank.unavailable" : "tbank.requestError";
- const isPending = update.isPending || remove.isPending;
- const isConnected = status.connected;
- useEffect(() => {
- if (isDeleteOpened && remove.isError) removalButtonRef.current?.focus();
- }, [isDeleteOpened, remove.isError]);
+ removalButtonRef,
+ remove,
+ requestError,
+ status,
+ t,
+ unavailableMessage,
+ update,
+ } = useTbankConnectionSectionLoadedVm(props);
return (
{t("tbank.title")}
+
{isConnected
? t("tbank.connected", {
@@ -83,18 +56,22 @@ const TbankConnectionSectionLoaded: FC = (
})
: t("tbank.disconnected")}
+
{t("tbank.hint")}
+
{props.refreshing && (
{t("tbank.refreshing")}
)}
+
{props.refreshError && (
{t("tbank.refreshError")}
+
)}
-
+
!isPending && setIsDeleteOpened(false)}
+ onClose={onCloseDisconnect}
opened={isDeleteOpened}
title={t("tbank.deleteTitle")}
>
{t("tbank.deleteText")}
+
{remove.isError && (
{t("tbank.requestError")}
)}
+
,
);
await screen.findByLabelText("Период");
+
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
@@ -192,6 +208,7 @@ describe("Upcoming event date filters", () => {
);
await screen.findByLabelText("Период");
}
+
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
@@ -227,6 +244,7 @@ describe("Upcoming event date filters", () => {
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
+
}
@@ -258,6 +276,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
+
return HttpResponse.json(
resource({
accountId,
@@ -278,6 +297,7 @@ describe("Upcoming event date filters", () => {
]}
>
+
}
@@ -333,6 +353,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
+
const events = [
{
date: "2026-10-01",
@@ -341,16 +362,19 @@ it("retains the previous complete forecast and timestamp when its source refresh
type: "coupon",
},
];
+
const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
);
+
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(snapshot),
),
);
const queryClient = createTestQueryClient();
+
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
wrapper: ({ children }) => (
@@ -358,6 +382,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
),
});
+
await waitFor(() =>
expect(result.current).toMatchObject({
events,
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
index 1532319..22268e8 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
@@ -37,7 +37,8 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
refresh.mockClear();
- query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ query.mockReturnValue(
{
data: {
accountId: "account",
@@ -51,8 +52,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
- }
- /* eslint-enable unicorn/no-null */) as never);
+ } as /* eslint-enable unicorn/no-null */ never,
+ );
});
it("passes the selected route account to the query", () => {
@@ -72,7 +73,8 @@ describe("AccountEventsPage", () => {
});
it("keeps the loading fallback when no result or account is available", () => {
- query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -82,8 +84,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
- }
- /* eslint-enable unicorn/no-null */) as never);
+ } as /* eslint-enable unicorn/no-null */ never,
+ );
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
@@ -93,7 +95,8 @@ describe("AccountEventsPage", () => {
});
it("renders events and marks an offer without an amount", () => {
- query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ query.mockReturnValue(
{
data: {
accountId: "account",
@@ -132,8 +135,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
- }
- /* eslint-enable unicorn/no-null */) as never);
+ } as /* eslint-enable unicorn/no-null */ never,
+ );
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
@@ -180,7 +183,8 @@ describe("AccountEventsPage", () => {
});
it("shows a structural busy state before events are available", () => {
- query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ query.mockReturnValue(
{
data: undefined,
isError: false,
@@ -190,8 +194,8 @@ describe("AccountEventsPage", () => {
sync: { errorCode: null, id: null, state: "idle" },
},
refetch: vi.fn(),
- }
- /* eslint-enable unicorn/no-null */) as never);
+ } as /* eslint-enable unicorn/no-null */ never,
+ );
renderPage();
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx
index 247e587..f12859a 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx
@@ -10,7 +10,14 @@ type AccountEventsPageProps = { accountId?: string };
const AccountEventsPage: FC = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return ;
- return viewModel.state === "error" ? : ;
+
+ return viewModel.state === "error"
+ ? (
+
+ )
+ : (
+
+ );
};
export { AccountEventsPage };
diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
index 0fd1c23..5225ef9 100644
--- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
-import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
+import { isValidPeriod } from "@/shared/lib";
type AccountEventsPageViewModel
= | {
@@ -38,8 +38,15 @@ function useAccountEventsPageViewModel(
const urlFrom = searchParameters.get("from");
const urlTo = searchParameters.get("to");
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
- const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
- const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+
+ const appliedFrom = isValidUrlPeriod
+ ? urlFrom
+ : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+
+ const appliedTo = isValidUrlPeriod
+ ? urlTo
+ : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+
const [draftFrom, setDraftFrom] = useState(appliedFrom);
const [draftTo, setDraftTo] = useState(appliedTo);
useEffect(() => {
@@ -49,19 +56,23 @@ function useAccountEventsPageViewModel(
normalized.delete("to");
setSearchParameters(normalized, { replace: true });
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
+
const [previousPeriod, setPreviousPeriod] = useState([
appliedFrom,
appliedTo,
]);
+
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
setPreviousPeriod([appliedFrom, appliedTo]);
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
}
+
const events = useBrokerageEvents(accountId, {
...(appliedFrom && { from: appliedFrom }),
...(appliedTo && { to: appliedTo }),
});
+
const sync = useScreenSync(
{
accountId,
@@ -71,12 +82,15 @@ function useAccountEventsPageViewModel(
},
[events],
);
+
if (events.isLoading) return { state: "loading" };
+
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParameters({});
};
+
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
onReset,
@@ -86,6 +100,7 @@ function useAccountEventsPageViewModel(
state: "error",
};
if (!events.data) return { state: "loading" };
+
return {
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
@@ -115,14 +130,4 @@ function useAccountEventsPageViewModel(
};
}
-const calendarDate = z.iso.date();
-function isValidPeriod(from: null | string, to: null | string) {
- return (
- [from, to].every(
- date => date === null || calendarDate.safeParse(date).success,
- )
- && (!from || !to || from <= to)
- );
-}
-
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
index 160df64..3f929e6 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
+
return (
);
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
index dd0156a..9630812 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
@@ -8,11 +8,13 @@ type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
const AccountEventsPageError: FC = (props) => {
const { t } = useTranslation();
+
return (
{t("events.retry")}
+
{t("ledger.reset")}
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
index 0415a3f..1f3724b 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -10,7 +10,6 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
-import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
@@ -18,82 +17,94 @@ import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
+import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm";
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
-const AccountEventsPageLoaded: FC = (props) => {
- const { t } = useTranslation();
- function renderEvents() {
- if (props.events.length > 0) {
- return (
-
- {props.events.map(event => (
-
- ))}
-
- );
- }
- if (props.partial) {
- return;
- }
- return ;
+const AccountEventsPageLoadedEvents: FC = (
+ props,
+) => {
+ if (props.events.length > 0) {
+ return (
+
+ {props.events.map(event => (
+
+ ))}
+
+ );
}
+
+ if (props.partial) {
+ return;
+ }
+
+ return ;
+};
+
+const AccountEventsPageLoaded: FC = (props) => {
+ const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props);
+
return (
- {!!(props.lastSuccessfulAt) && ((
+ {!!props.lastSuccessfulAt && (
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
- ))}
+ )}
+
{t("events.title")}
+
{t("investment.refresh")}
- {!!(props.partial) && ((
+
+ {!!props.partial && (
{t("investment.partial")}
- ))}
- {!!(props.refreshing) && ((
+ )}
+
+ {!!props.refreshing && (
- ))}
- {!!(props.refreshError) && ((
+ )}
+
+ {!!props.refreshError && (
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
+
{t("accounts.retry")}
- ))}
+ )}
+
{t("events.hint")}
+
{
- props.onDraftFromChange(from);
- props.onDraftToChange(to);
- }}
+ onChange={onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
@@ -103,15 +114,18 @@ const AccountEventsPageLoaded: FC = (props) => {
valueFormat="DD.MM.YYYY"
/>
+
{t("ledger.apply")}
+
{t("ledger.reset")}
- {renderEvents()}
+
+
);
};
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts
new file mode 100644
index 0000000..af03a32
--- /dev/null
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.vm.ts
@@ -0,0 +1,24 @@
+import { useTranslation } from "react-i18next";
+
+import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
+
+type AccountEventsPageLoadedProps = Extract<
+ AccountEventsPageViewModel,
+ { state: "loaded" }
+>;
+
+function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) {
+ const { t } = useTranslation();
+
+ const onPeriodChange: (value: [null | string, null | string]) => void = ([
+ from,
+ to,
+ ]) => {
+ props.onDraftFromChange(from);
+ props.onDraftToChange(to);
+ };
+
+ return { onPeriodChange, t };
+}
+
+export { useAccountEventsPageLoadedVm };
diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
index a9de6bd..755d112 100644
--- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
@@ -8,18 +8,25 @@ const skeletonRowIds = ["first", "second", "third", "fourth"];
const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation();
+
return (
+
+
+
+
+
+
{skeletonRowIds.map(id => (
))}
diff --git a/src/pages/account-income/model/income.schema.ts b/src/pages/account-income/model/income.schema.ts
new file mode 100644
index 0000000..9d22512
--- /dev/null
+++ b/src/pages/account-income/model/income.schema.ts
@@ -0,0 +1,31 @@
+import { z } from "zod";
+
+import { validationMessage } from "@/shared/i18n";
+
+import { moscowDate, parseDraftDate } from "./period";
+
+const draftDate = z
+ .string()
+ .refine(value => parseDraftDate(value) !== null, {
+ error: validationMessage("income.validation.date"),
+ })
+ .refine(
+ (value) => {
+ const date = parseDraftDate(value);
+
+ return !date || date <= moscowDate(new Date());
+ },
+ { error: validationMessage("income.validation.future") },
+ );
+
+const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
+ ({ from, to }) => {
+ const start = parseDraftDate(from);
+ const end = parseDraftDate(to);
+
+ return !start || !end || start <= end;
+ },
+ { path: ["to"], ...validationMessage("income.validation.period")({}) },
+);
+
+export { incomeFormSchema };
diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts
index 110e056..b0d002f 100644
--- a/src/pages/account-income/model/period.ts
+++ b/src/pages/account-income/model/period.ts
@@ -7,12 +7,14 @@ const datePattern = /^\d{4}-\d{2}-\d{2}$/;
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-", DATE_PART_COUNT);
+
return `${day}.${month}.${year}`;
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
+
return (
Number.isFinite(parsed.valueOf())
&& parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value
@@ -26,19 +28,27 @@ function moscowDate(value: Date): string {
timeZone: "Europe/Moscow",
year: "numeric",
}).formatToParts(value);
+
const part = (type: string) =>
parts.find(item => item.type === type)?.value;
+
return `${part("year")}-${part("month")}-${part("day")}`;
}
function parseDraftDate(value: string): null | string {
- if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
+ if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
+ // eslint-disable-next-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
+ return null;
const [day, month, year] = value.split(".", DATE_PART_COUNT);
const date = `${year}-${month}-${day}`;
- return isDate(date) ? date : /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null;
+
+ return isDate(date)
+ ? date
+ : null; // eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date.
}
-export { formatDraftDate,
+export {
+ formatDraftDate,
type IncomePeriod as IncomeForm,
type IncomePeriod,
isDate,
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
index fe6240e..93bbea9 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
@@ -1,6 +1,5 @@
import type { FC } from "react";
-import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -13,6 +12,7 @@ import {
waitFor,
within,
} from "@testing-library/react";
+import "dayjs/locale/ru";
import { http, HttpResponse } from "msw";
import {
MemoryRouter,
@@ -38,49 +38,50 @@ import {
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
-const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
-{
- accountId: "account-1",
- bucketGranularity: "day",
- buckets: [
- {
- period: { from: "2026-08-01", to: "2026-08-01" },
- totalsByCurrency: [summary("RUB", "10", "2")],
+/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+const analytics
+ = {
+ accountId: "account-1",
+ bucketGranularity: "day",
+ buckets: [
+ {
+ period: { from: "2026-08-01", to: "2026-08-01" },
+ totalsByCurrency: [summary("RUB", "10", "2")],
+ },
+ ],
+ calculatedAt: "2026-08-27T00:00:00Z",
+ entries: [
+ entry(),
+ {
+ ...entry(),
+ id: "dividend-1",
+ payment: money("RUB", "2"),
+ type: "OPERATION_TYPE_DIVIDEND",
+ },
+ ],
+ instruments: [
+ {
+ coupon: money("RUB", "10"),
+ displayName: "BOND",
+ dividend: money("RUB", "2"),
+ figi: "FIGI",
+ instrumentType: "bond",
+ instrumentUid: null,
+ key: "income-group-opaque-v2",
+ receiptCount: 2,
+ ticker: "BOND",
+ total: money("RUB", "12"),
+ },
+ ],
+ period: { from: "2026-08-01", to: "2026-08-27" },
+ previous: {
+ period: { from: "2026-07-01", to: "2026-07-31" },
+ totalsByCurrency: [comparison("RUB")],
},
- ],
- calculatedAt: "2026-08-27T00:00:00Z",
- entries: [
- entry(),
- {
- ...entry(),
- id: "dividend-1",
- payment: money("RUB", "2"),
- type: "OPERATION_TYPE_DIVIDEND",
- },
- ],
- instruments: [
- {
- coupon: money("RUB", "10"),
- displayName: "BOND",
- dividend: money("RUB", "2"),
- figi: "FIGI",
- instrumentType: "bond",
- instrumentUid: null,
- key: "income-group-opaque-v2",
- receiptCount: 2,
- ticker: "BOND",
- total: money("RUB", "12"),
- },
- ],
- period: { from: "2026-08-01", to: "2026-08-27" },
- previous: {
- period: { from: "2026-07-01", to: "2026-07-31" },
- totalsByCurrency: [comparison("RUB")],
- },
- totalsByCurrency: [summary("RUB", "10", "2")],
- yearComparison: null,
-}
-/* eslint-enable unicorn/no-null */) satisfies IncomeAnalytics;
+ totalsByCurrency: [summary("RUB", "10", "2")],
+ yearComparison: null,
+ } satisfies /* eslint-enable unicorn/no-null */ IncomeAnalytics;
function comparison(currency: string) {
return {
@@ -92,7 +93,8 @@ function comparison(currency: string) {
}
function entry(): IncomeAnalytics["entries"][number] {
- return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ return (
{
accruedInterest: null,
category: "income",
@@ -111,7 +113,8 @@ function entry(): IncomeAnalytics["entries"][number] {
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
- /* eslint-enable unicorn/no-null */);
+ /* eslint-enable unicorn/no-null */
+ );
}
function money(currency: string, value: string) {
@@ -125,6 +128,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
refresh: () => Promise.resolve(true),
});
const queryClient = createTestQueryClient();
+
const view = render(
@@ -133,11 +137,13 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
initialEntries={[`/accounts/account-1/income?${search}`]}
>
+
}
path="/accounts/:accountId/income"
/>
+
Events}
path="/accounts/:accountId/events"
@@ -148,6 +154,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
,
);
+
return { ...view, queryClient };
}
@@ -171,10 +178,12 @@ describe("AccountIncomePage", () => {
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
+
return new HttpResponse(null, { status: 503 });
}),
);
const queryClient = createTestQueryClient();
+
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
@@ -184,6 +193,7 @@ describe("AccountIncomePage", () => {
),
});
+
await new Promise(resolve => setTimeout(resolve, 0));
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
@@ -198,21 +208,26 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
+
return HttpResponse.json(
- resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ resource(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ null,
{
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed",
},
- }
- /* eslint-enable unicorn/no-null */)),
+ },
+ /* eslint-enable unicorn/no-null */
+ ),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
+
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -243,6 +258,7 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
+
return HttpResponse.json(resource(analytics));
},
),
@@ -255,18 +271,21 @@ describe("AccountIncomePage", () => {
it("preserves every repeated-ID payment when an open card updates", async () => {
const first = { ...entry(), description: "Выплата A", id: "same-id" };
+
const second = {
...entry(),
description: "Выплата B",
id: "same-id",
payment: money("RUB", "-2"),
};
+
const third = {
...entry(),
description: "Выплата C",
id: "other-id",
payment: money("RUB", "1"),
};
+
function breakdown(entries: IncomeAnalytics["entries"], total: string) {
const display = toIncomeDisplayModel({
...analytics,
@@ -281,19 +300,24 @@ describe("AccountIncomePage", () => {
},
],
});
+
expect(display).not.toBeNull();
+
return (
);
}
+
const { rerender } = render(breakdown([first, second, third], "9"));
const panel = await screen.findByRole("region", { name: "Выплаты" });
+
const payments = () =>
within(panel)
.getAllByText(/^Выплата [ABC]$/)
.map(element => element.textContent);
+
expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]);
rerender(breakdown([third, first, second], "9"));
@@ -323,6 +347,7 @@ describe("AccountIncomePage", () => {
analytics.entries[1],
],
};
+
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -409,8 +434,9 @@ describe("AccountIncomePage", () => {
});
it("renders the signed drill-down using opaque membership rather than instrument identity", async () => {
- const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ const response
+ = {
...analytics,
entries: [
{
@@ -436,8 +462,9 @@ describe("AccountIncomePage", () => {
total: money("RUB", "8"),
},
],
- }
- /* eslint-enable unicorn/no-null */);
+ };
+
+ /* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
@@ -486,9 +513,11 @@ describe("AccountIncomePage", () => {
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const year = screen.getByRole("region", { name: "Годом ранее" });
+
const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
+
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
@@ -502,13 +531,17 @@ describe("AccountIncomePage", () => {
).toBeInTheDocument();
expect(screen.queryByText("Сравнения")).not.toBeInTheDocument();
expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument();
+
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
+
const difference = within(year).getByRole("region", { name: "Разница" });
+
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
+
expect(within(difference).getByText("+900 %")).toBeInTheDocument();
expect(within(difference).getByText("+1 100 %")).toBeInTheDocument();
expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument();
@@ -523,14 +556,13 @@ describe("AccountIncomePage", () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
- (
- {
- ...resource(null).meta,
- role: "year" as const,
- unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
- }
- ),
+ {
+ ...resource(null).meta,
+ role: "year" as const,
+ unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
+ },
];
+
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
@@ -572,16 +604,47 @@ describe("income comparison display", () => {
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
- ["10", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
- ["0", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
- ["10", "-0.00", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
- ["10", "-100", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
- ["9".repeat(400), "1", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
- ["1", "9".repeat(400), /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null],
+ [
+ "10",
+ "0",
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
+ [
+ "0",
+ "0",
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
+ [
+ "10",
+ "-0.00",
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
+ [
+ "10",
+ "-100",
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
+ [
+ "9".repeat(400),
+ "1",
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
+ [
+ "1",
+ "9".repeat(400),
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
+ ],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
+
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
@@ -589,10 +652,13 @@ describe("income comparison display", () => {
totalsByCurrency: [value],
},
});
+
const result = display?.comparison?.values[0];
- expect(result?.percentages.coupon?.replaceAll("\u{A0}", " ") ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe(
- expected,
- );
+ expect(
+ result?.percentages.coupon?.replaceAll("\u{A0}", " ")
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ ?? null,
+ ).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
@@ -606,6 +672,7 @@ describe("income comparison display", () => {
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
+
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
);
@@ -622,9 +689,11 @@ describe("income comparison display", () => {
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
+
return (
<>
+
navigate(-1)} type="button">
Back
@@ -639,6 +708,7 @@ function respondWithRequestedPeriod(requests: string[]) {
({ request }) => {
const url = new URL(request.url);
requests.push(url.search);
+
return HttpResponse.json(
resource({
...analytics,
@@ -753,12 +823,15 @@ describe("income period and snapshot boundary", () => {
await screen.findByLabelText("Период");
selectPeriod();
- const { promise: pending, resolve: release } = Promise.withResolvers();
+ const { promise: pending, resolve: release }
+ = Promise.withResolvers();
+
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
+
return new HttpResponse(null, { status: 503 });
},
),
@@ -790,7 +863,8 @@ describe("income period and snapshot boundary", () => {
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
- HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ HttpResponse.json(
{
errorCode: null,
id: "income",
@@ -799,8 +873,9 @@ describe("income period and snapshot boundary", () => {
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
- }
- /* eslint-enable unicorn/no-null */)),
+ },
+ /* eslint-enable unicorn/no-null */
+ ),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "2" })),
@@ -822,12 +897,15 @@ describe("income period and snapshot boundary", () => {
renderPage();
await screen.findByLabelText("Период");
- const { promise: pending, resolve: release } = Promise.withResolvers();
+ const { promise: pending, resolve: release }
+ = Promise.withResolvers();
+
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
+
return HttpResponse.json(
resource({
...analytics,
@@ -882,6 +960,7 @@ describe("income period and snapshot boundary", () => {
total: money("RUB", "-3"),
},
];
+
const display = toIncomeDisplayModel({
...analytics,
entries: instruments.map(instrument => ({
@@ -892,6 +971,7 @@ describe("income period and snapshot boundary", () => {
instruments,
totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
});
+
render(
@@ -917,8 +997,11 @@ describe("income period and snapshot boundary", () => {
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
- const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
+
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+
+ const previous
+ = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
@@ -927,16 +1010,16 @@ it("maps comparison notices by role regardless of dependency order or resource k
id: null,
state: "failed" as const,
},
- }
- /* eslint-enable unicorn/no-null */);
- const year = (
- {
- ...resource(null).meta,
- resourceKey: "opaque/previous-looking-key",
- role: "year" as const,
- unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
- }
- );
+ };
+
+ /* eslint-enable unicorn/no-null */
+ const year = {
+ ...resource(null).meta,
+ resourceKey: "opaque/previous-looking-key",
+ role: "year" as const,
+ unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
+ };
+
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
@@ -945,6 +1028,7 @@ it("maps comparison notices by role regardless of dependency order or resource k
...resource(analytics).meta,
dependencies,
});
+
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
@@ -957,21 +1041,18 @@ it.each([false, true])(
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
- (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- { ...analytics, previous: null, yearComparison: null }
- /* eslint-enable unicorn/no-null */),
- (
- {
- dependencies: legacy
- ? [
- resource(null).meta,
- resource(null).meta,
- resource(null).meta,
- ]
- : [],
- }
- ),
+ { ...analytics, previous: null, yearComparison: null },
+ /* eslint-enable unicorn/no-null */ {
+ dependencies: legacy
+ ? [
+ resource(null).meta,
+ resource(null).meta,
+ resource(null).meta,
+ ]
+ : [],
+ },
),
),
),
@@ -992,21 +1073,22 @@ it.each([false, true])(
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
- role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ role =>
+ ({
...resource(role === "selected" ? analytics : null).meta,
role,
- }
- /* eslint-enable unicorn/no-null */)),
+ }),
+ /* eslint-enable unicorn/no-null */
);
+
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
resource(
- (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- { ...analytics, previous: null, yearComparison: null }
- /* eslint-enable unicorn/no-null */),
- { dependencies },
+ { ...analytics, previous: null, yearComparison: null },
+ /* eslint-enable unicorn/no-null */ { dependencies },
),
),
),
@@ -1031,20 +1113,26 @@ it("shows selected income before comparison coverage without inventing a zero ba
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
- { ...resource(analytics).meta, role: "previous" as const },
- (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- {
- ...resource(null).meta,
- role: "year" as const,
- sync: {
- errorCode: "TBANK_UNAVAILABLE",
- id: null,
- state: "failed" as const,
- },
- }
- /* eslint-enable unicorn/no-null */),
- { ...resource(analytics).meta, role: "selected" as const },
+ {
+ ...resource(analytics).meta,
+ role: "previous" as const,
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ },
+ {
+ ...resource(null).meta,
+ role: "year" as const,
+ sync: {
+ errorCode: "TBANK_UNAVAILABLE",
+ id: null,
+ state: "failed" as const,
+ },
+ },
+ /* eslint-enable unicorn/no-null */ {
+ ...resource(analytics).meta,
+ role: "selected" as const,
+ },
];
+
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { dependencies })),
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx
index bdbfab6..353f09d 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx
@@ -10,7 +10,14 @@ type AccountIncomePageProps = { accountId?: string };
const AccountIncomePage: FC = (props) => {
const viewModel = useAccountIncomePageViewModel(props.accountId);
if (viewModel.state === "loading") return ;
- return viewModel.state === "error" ? : ;
+
+ return viewModel.state === "error"
+ ? (
+
+ )
+ : (
+
+ );
};
export { AccountIncomePage };
diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
index ef00e28..c11cf4b 100644
--- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
@@ -2,13 +2,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
-import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
-import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
+import { incomeFormSchema } from "../../model/income.schema";
import {
formatDraftDate,
type IncomeForm,
@@ -43,6 +42,7 @@ type AccountIncomePageViewModel
state: "error";
}
| { state: "loading" };
+
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
@@ -50,15 +50,18 @@ type ComparisonNotice
| "yearUnavailable";
type IncomeDisplayModel = {
- comparison: null | {
- comparedPeriod: string;
- selectedPeriod: string;
- values: (NonNullable<
- IncomeAnalytics["yearComparison"]
- >["totalsByCurrency"][number] & {
- percentages: Record<"coupon" | "dividend" | "total", null | string>;
- })[];
- };
+ comparison: null
+ | {
+ comparedPeriod: string;
+ selectedPeriod: string;
+ values: (NonNullable<
+ IncomeAnalytics["yearComparison"]
+ >["totalsByCurrency"][number] & {
+ percentages: Record<"coupon"
+ | "dividend"
+ | "total", null | string>;
+ })[];
+ };
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
@@ -67,33 +70,13 @@ type IncomeDisplayModel = {
type IncomeMeta = ReturnType["meta"];
-const draftDate = z
- .string()
- .refine(value => parseDraftDate(value) !== null, {
- error: validationMessage("income.validation.date"),
- })
- .refine(
- (value) => {
- const date = parseDraftDate(value);
- return !date || date <= moscowDate(new Date());
- },
- { error: validationMessage("income.validation.future") },
- );
-const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
- ({ from, to }) => {
- const start = parseDraftDate(from);
- const end = parseDraftDate(to);
- return !start || !end || start <= end;
- },
- { path: ["to"], ...validationMessage("income.validation.period")({}) },
-);
-
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParameters, setSearchParameters] = useSearchParams();
+
const requested = useMemo(
() => ({
from: searchParameters.get("from") ?? "",
@@ -101,31 +84,39 @@ const useAccountIncomePageViewModel = (
}),
[searchParameters],
);
+
const period = useMemo(
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
[requested],
);
+
const form = useForm({
defaultValues: draftPeriod(period),
resolver: zodResolver(incomeFormSchema),
});
+
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
+
const sync = useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
useEffect(() => {
- if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true });
+ if (!isValidPeriod(requested))
+ setSearchParameters(period, { replace: true });
}, [period, requested, setSearchParameters]);
useEffect(() => {
form.reset(draftPeriod(period));
}, [form, period]);
if (analytics.isLoading || !analytics.data) {
- return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" };
+ return analytics.isError || analytics.meta?.sync.state === "failed"
+ ? { period, retry: sync.retry, state: "error" }
+ : { state: "loading" };
}
+
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
@@ -133,11 +124,13 @@ const useAccountIncomePageViewModel = (
retry: () => analytics.observe(),
state: "error",
};
+
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
const to = parseDraftDate(values.to);
if (from && to) setSearchParameters({ from, to });
};
+
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
@@ -145,6 +138,7 @@ const useAccountIncomePageViewModel = (
setResetRevision(revision => revision + 1);
setSearchParameters(next, { replace: true });
};
+
return {
analytics: analytics.data,
display,
@@ -166,7 +160,10 @@ function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
- if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ if (!comparison)
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ return null;
+
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
@@ -192,11 +189,11 @@ function comparisonDisplay(
function comparisonNotice(
dependency:
- | NonNullable["dependencies"]>[number]
- | undefined,
+ NonNullable["dependencies"]>[number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
+
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
@@ -204,6 +201,7 @@ function comparisonNotice(
function defaultPeriod(): IncomePeriod {
const today = moscowDate(new Date());
+
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
}
@@ -221,9 +219,14 @@ function formatIncomeChange(
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
- if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ if (!Number.isFinite(base) || base <= 0)
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ return null;
const ratio = Number(difference) / base;
- if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ if (!Number.isFinite(ratio))
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ return null;
+
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
@@ -236,6 +239,7 @@ function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
const from = new Date(`${period.from}T00:00:00Z`);
const to = new Date(`${period.to}T00:00:00Z`);
const today = new Date(`${moscowDate(new Date())}T00:00:00Z`);
+
return (
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
@@ -256,9 +260,11 @@ function toIncomeDisplayModel(
receiptCount: number;
}
>();
+
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
+
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
@@ -274,8 +280,10 @@ function toIncomeDisplayModel(
receiptCount: instrument.receiptCount,
});
}
+
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map();
+
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
@@ -298,12 +306,16 @@ function toIncomeDisplayModel(
operation: entry,
});
}
+
for (const instrument of instruments.values()) {
- if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ if (instrument.includedCount !== instrument.receiptCount)
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ return null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
+
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
index 51ad1a6..e4603ea 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
@@ -10,6 +10,7 @@ type ErrorProps = Extract;
const AccountIncomePageError: FC = (props) => {
const { t } = useTranslation();
+
return (
{t("income.retry")}}
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
index cb5cfcd..67d4fb9 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
@@ -25,45 +25,46 @@ import { IncomeSummary } from "./IncomeSummary.view";
type LoadedProps = Extract;
-const AccountIncomePageLoaded: FC = (props) => {
- const {
- analytics,
- display,
- form,
- onReset,
- onSubmit,
- refreshError,
- refreshing,
- retry,
- } = props;
+const AccountIncomePageLoadedSummary: FC = (props) => {
const { t } = useTranslation();
- function renderSummary() {
- if (props.partial) {
- return {t("investment.partial")};
- }
- if (analytics.totalsByCurrency.length > 0) {
- return ;
- }
- return (
-
- );
+
+ const { analytics } = props;
+
+ if (props.partial) {
+ return {t("investment.partial")};
}
+
+ return analytics.totalsByCurrency.length > 0
+ ? (
+
+ )
+ : (
+
+ );
+};
+
+const AccountIncomePageLoaded: FC = (props) => {
+ const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
+ = props;
+
+ const { t } = useTranslation();
+
return (
- {!!(props.lastSuccessfulAt) && ((
+ {!!props.lastSuccessfulAt && (
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
- ))}
+ )}
+
{t("income.title")}
+
{t("investment.refresh")}
+
= (props) => {
onSubmit={form.handleSubmit(onSubmit)}
/>
- {!!(refreshing) && ((
+
+ {!!refreshing && (
+
{t("income.refreshing")}
- ))}
- {!!(refreshError) && ((
+ )}
+
+ {!!refreshError && (
@@ -87,24 +91,30 @@ const AccountIncomePageLoaded: FC = (props) => {
: "income.refreshError",
)}
+
{t("income.retry")}
- ))}
- {renderSummary()}
+ )}
+
+
+
{t("income.taxBoundary")}
+
+
+
);
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
index b51136c..235d38d 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
@@ -8,24 +8,34 @@ import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
const { t } = useTranslation();
+
return (
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
index 9acf384..e01d963 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
@@ -12,12 +12,16 @@ import classes from "../AccountIncomePage.module.css";
type ComparisonProps = NonNullable & {
title: string;
};
+
type ComparisonValuesProps = {
- kind: "compared" | "difference" | "selected";
+ kind: "compared"
+ | "difference"
+ | "selected";
label: string;
percentages?: ComparisonProps["values"][number]["percentages"];
values: ComparisonProps["values"][number]["selected"];
};
+
type IncomeComparisonsProps = {
comparison: IncomeDisplayModel["comparison"];
notice: string;
@@ -28,6 +32,7 @@ const IncomeComparisons: FC = (props) => {
return (
{props.title}
+
{props.comparison
? (
@@ -45,6 +50,7 @@ const IncomeComparisons: FC = (props) => {
const Comparison: FC = (props) => {
const { t } = useTranslation();
+
return (
@@ -61,11 +67,13 @@ const Comparison: FC = (props) => {
label={props.selectedPeriod}
values={value.selected}
/>
+
+
= (props) => {
))}
- {props.values.length === 0 && ((
+
+ {props.values.length === 0 && (
{t("income.comparisonEmpty")}
- ))}
+ )}
);
@@ -87,46 +96,59 @@ const Comparison: FC = (props) => {
const ComparisonValues: FC = (props) => {
const { t } = useTranslation();
+
+ const data_tone
+ = props.kind === "difference"
+ ? differenceTone(props.values.total.value)
+ : undefined;
+
return (
{props.label}
+
{(["total", "coupon", "dividend"] as const).map((key) => {
- const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key];
- const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key];
+ const money
+ // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
+ = props.values[key];
+
+ const percentage
+ // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
+ = props.percentages?.[key];
+
const tone
= props.kind === "difference"
? differenceTone(money.value)
: undefined;
+
+ const appearanceClass
+ = key === "total"
+ ? classes.comparisonTotal
+ : classes.comparisonDetail;
+
return (
-
+
- {t(`income.${key}`)}
+
-
{tone === "positive" ? "+" : ""}
+
{formatMoney(money)}
- {!!(percentage) && ((
+
+ {!!percentage && (
{percentage}
- ))}
+ )}
);
@@ -136,8 +158,11 @@ const ComparisonValues: FC
= (props) => {
);
};
-function differenceTone(value: string): "negative" | "neutral" | "positive" {
+function differenceTone(value: string): "negative"
+ | "neutral"
+ | "positive" {
if (!/[1-9]/.test(value)) return "neutral";
+
return value.startsWith("-") ? "negative" : "positive";
}
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
index cf418ed..fd85150 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx
@@ -10,13 +10,16 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
+
type LoadedProps = Extract;
const IncomePayments: FC = (props) => {
const { t } = useTranslation();
+
return (
{t("income.payments")}
+
{props.entries.map(({ key, operation }) => (
-
diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx
index 8109871..eb26348 100644
--- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx
@@ -8,6 +8,7 @@ import type { IncomeForm } from "../../../../model/period";
import { IncomePeriodForm } from "./IncomePeriodForm.view";
const period = { from: "01.08.2026", to: "27.08.2026" };
+
const errors = {
to: { message: "Синтетическая ошибка периода", type: "manual" },
};
@@ -19,8 +20,13 @@ const PeriodExample: FC = (props) => {
defaultValues: period,
errors: props.invalid ? errors : undefined,
});
+
const { isSubmitSuccessful } = useFormState({ control: methods.control });
- const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
+
+ const submitted = isSubmitSuccessful
+ ? methods.getValues()
+ : null; // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+
return (
= (props) => {
}}
onSubmit={methods.handleSubmit(() => {})}
/>
+
+
= (props) => {
{t("investment.refresh")}
- {!!(vm.company?.description) && ((
+
+ {!!vm.company?.description && (
{vm.company.description}
- ))}
- {!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
+ )}
+
+ {!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
- ))}
- {vm.quoteState.loading
- ? (
-
-
-
- )
- : (
-
-
- {formatMoney(vm.quote?.price)}
-
- {!!(vm.quote?.change) && ((
-
- {vm.quote.change.value.startsWith("-") ? "" : "+"}
- {formatMoney(vm.quote.change)}
- {vm.quote.changePercent === null
- ? ""
- : ` (${formatDecimal(vm.quote.changePercent)} %)`}
-
- ))}
- {vm.quote?.previousCloseDate
- ? (
-
- {t("stock.changeBase", {
- date: formatDate(vm.quote.previousCloseDate),
- })}
-
- )
- : (
-
- {t("stock.noComparison")}
-
- )}
-
-
- {t(`stock.trading.${vm.quote?.tradingStatus}`, {
- defaultValue: t("stock.trading.unknown"),
- })}
-
- {!!(vm.quote?.priceAt) && ((
-
- {t("stock.priceAt", {
- date: formatSnapshotTime(vm.quote.priceAt),
- })}
-
- ))}
-
- {!!(vm.quote?.unavailable.length) && ((
-
- {t("stock.partialQuote")}
-
- ))}
- {!!(vm.quoteState.refreshing) && ((
-
- {t("accounts.refreshing")}
-
- ))}
-
- )}
+ )}
+
+ {quoteContent}
+
- {!!(stock.sector) && ((
+ {!!stock.sector && (
- {t("stock.sector")}
- :
- {" "}
+ {t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
- ))}
- {stock.lot !== null && ((
+ )}
+
+ {stock.lot !== null && (
{t("stock.lot", { value: stock.lot })}
- ))}
+ )}
- {!!(vm.company) && ((
+
+ {!!vm.company && (
- {vm.company.unavailable.length > 0 && ((
+ {vm.company.unavailable.length > 0 && (
{t("stock.partialCompany")}
+
= (props) => {
- ))}
- {vm.company.metrics.length > 0
- ? (
-
- {vm.company.metrics.map(metric => (
-
-
- {t(`stock.metrics.${metric.key}`)}
- {metric.period ? ` · ${metric.period}` : ""}
-
-
- {new Intl.NumberFormat("ru-RU", {
- maximumFractionDigits: 2,
- notation:
- metric.unit === "money" ? "compact" : "standard",
- }).format(metric.value)}
- {" "}
- {metricUnit(metric, t("stock.currencyUnknown"))}
-
-
- ))}
-
- )
- : (
- {t("stock.noFundamentals")}
- )}
+ )}
+
+ {companyMetricsContent}
+
{t("stock.periodNote")}
- ))}
+ )}
+
+
+
+
);
};
-function metricUnit(metric: NonNullable["metrics"][number], unknownCurrency: string) {
- if (metric.unit === "money") return metric.currency === "RUB" ? "₽" : (metric.currency ?? unknownCurrency);
+function metricUnit(
+ metric: NonNullable["metrics"][number],
+ unknownCurrency: string,
+) {
+ if (metric.unit === "money")
+ return metric.currency === "RUB"
+ ? "₽"
+ : (metric.currency ?? unknownCurrency);
+
return metric.unit === "percent" ? "%" : "×";
}
diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts
index 4770d73..49ecd53 100644
--- a/src/pages/stock/ui/StockPage/StockPage.vm.ts
+++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts
@@ -21,68 +21,93 @@ import { formatDate, instrumentLogoUrl } from "@/shared/lib";
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const;
+
type StockEventFilter = (typeof filters)[number];
+
const categories: Record = {
all: [],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
+
type StockPageVm = ReturnType;
+
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.data?.instrumentUid ?? "";
const [parameters, setParameters] = useSearchParams();
+
const period
= periods.find(value => value === parameters.get("period")) ?? "month";
+
const filter
= filters.find(value => value === parameters.get("events")) ?? "all";
+
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
- const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
+
+ const history = useInstrumentOperations(
+ uid,
+ filter,
+ // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
+ categories[filter],
+ );
+
const { operations } = history;
+
const sync = useScreenSync(
{
accountId: "all",
candlePeriod: period,
- categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
+ categories:
+ // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
+ categories[filter],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
[stock, quote, candles, position, company, dividends, operations],
);
+
const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters);
next.set(key, value);
setParameters(next, { replace: true });
};
+
const chartData
= candles.data?.candles.map(candle => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
+
const average = position.data?.averagePurchasePrice;
+
const averageLine
= average && average.currency === candles.data?.currency
? Number(average.value)
- : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
+ : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+
const companyData = company.data;
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
+
return {
averageLine:
- averageLine !== null && Number.isFinite(averageLine) ? averageLine : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
+ averageLine !== null && Number.isFinite(averageLine)
+ ? averageLine
+ : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
- : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
+ : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
chartData,
code: apiErrorCode(stock.error),
company: companyData,
@@ -92,8 +117,16 @@ function useStockPageVm() {
eventRows:
operations.data?.items.map(item => ({
...item,
- logoName: item.logoName ?? stock.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
- ticker: item.ticker ?? stock.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
+ logoName:
+ item.logoName
+ ?? stock.data?.logoName
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ ?? null,
+ ticker:
+ item.ticker
+ ?? stock.data?.ticker
+ // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
+ ?? null,
})) ?? [],
filter,
hasIncompleteCandle:
diff --git a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx b/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx
index ce03243..060f027 100644
--- a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx
+++ b/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx
@@ -19,100 +19,186 @@ import type { StockPageVm } from "../StockPage.vm";
import classes from "../StockPage.module.css";
type StockEventsProps = { vm: StockPageVm };
+
const StockDividends: FC = (props) => {
const { vm } = props;
const { t } = useTranslation();
+
+ let upcomingContent;
+
+ if (vm.dividends) {
+ if (vm.upcoming.length > 0) {
+ upcomingContent = (
+ vm.upcoming.map(event => (
+
+
+
+
+ {t("stock.companyEvent")}
+
+
+
+ {formatMoney(event.perShare)}
+ {" "}
+
+ {t("stock.perShare")}
+
+
+
+
+ {!!event.estimatedAmount && (
+
+
+ {t("stock.yourEstimate")}
+
+
+
+ {formatMoney(event.estimatedAmount)}
+
+
+ )}
+
+
+
+ {!!event.lastBuyDate && (
+
+
+ {t("stock.lastBuy")}
+
+
+ {formatDate(event.lastBuyDate)}
+
+ )}
+
+ {!!event.recordDate && (
+
+
+ {t("stock.record")}
+
+
+ {formatDate(event.recordDate)}
+
+ )}
+
+ {!!event.paymentDate && (
+
+
+ {t("stock.payment")}
+
+
+ {formatDate(event.paymentDate)}
+
+ )}
+
+
+ ))
+ );
+ }
+ else {
+ upcomingContent = (
+
+ {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
+
+ );
+ }
+ }
+
+ let pastDividendsContent;
+
+ if (vm.pastDividends.length > 0) {
+ pastDividendsContent = (
+
+ {vm.pastDividends.map(event => (
+
+
+
+
+ {t("stock.record")}
+
+
+
+ {event.recordDate
+ ? formatDate(event.recordDate)
+ : t("stock.dateUnknown")}
+
+
+ {!!event.paymentDate && (
+
+ {t("stock.payment")}:{" "}
+ {formatDate(event.paymentDate)}
+
+ )}
+
+
+
+
+ {formatMoney(event.perShare)}
+
+
+
+ {t("stock.perShare")}
+
+
+
+
+ ))}
+
+ );
+ }
+ else {
+ pastDividendsContent = (
+ {t("stock.noDividends")}
+ );
+ }
+
return (
- {!!(vm.dividends) && ((
+ {!!vm.dividends && (
- {!!(vm.dividends.estimateUnavailable || vm.dividends.positionIsStale) && ((
+ {!!(
+ vm.dividends.estimateUnavailable || vm.dividends.positionIsStale
+ ) && (
{t("stock.estimateUnavailable")}
- ))}
- {vm.upcoming.length > 0
- ? (
- vm.upcoming.map(event => (
-
-
-
-
- {t("stock.companyEvent")}
-
-
- {formatMoney(event.perShare)}
- {" "}
-
- {t("stock.perShare")}
-
-
-
- {!!(event.estimatedAmount) && ((
-
-
- {t("stock.yourEstimate")}
-
- {formatMoney(event.estimatedAmount)}
-
- ))}
-
-
- {!!(event.lastBuyDate) && ((
-
-
- {t("stock.lastBuy")}
-
- {formatDate(event.lastBuyDate)}
-
- ))}
- {!!(event.recordDate) && ((
-
-
- {t("stock.record")}
-
- {formatDate(event.recordDate)}
-
- ))}
- {!!(event.paymentDate) && ((
-
-
- {t("stock.payment")}
-
- {formatDate(event.paymentDate)}
-
- ))}
-
-
- ))
- )
- : (
-
- {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
-
- )}
+ )}
+
+ {upcomingContent}
+
{t("stock.dividendNote")}
- {!!(vm.dividends.positionAt) && ((
+
+ {!!vm.dividends.positionAt && (
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
- ))}
+ )}
+
{t("stock.dividendHistory")}
+
{t("stock.dividendWindow", {
@@ -120,60 +206,13 @@ const StockDividends: FC = (props) => {
to: formatDate(vm.dividends.to),
})}
- {vm.pastDividends.length > 0
- ? (
-
- {vm.pastDividends.map(event => (
-
-
-
-
- {t("stock.record")}
-
-
- {event.recordDate
- ? formatDate(event.recordDate)
- : t("stock.dateUnknown")}
-
- {!!(event.paymentDate) && ((
-
- {t("stock.payment")}
- :
- {" "}
- {formatDate(event.paymentDate)}
-
- ))}
-
-
-
- {formatMoney(event.perShare)}
-
-
- {t("stock.perShare")}
-
-
-
-
- ))}
-
- )
- : (
- {t("stock.noDividends")}
- )}
+
+ {pastDividendsContent}
- ))}
+ )}
);
};
diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx
index 3c7ba1d..522b16b 100644
--- a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx
+++ b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx
@@ -18,6 +18,7 @@ describe("stock search", () => {
server.use(
http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
+
return HttpResponse.json(
resource([
{
diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
index 76dab51..1c0f2d3 100644
--- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
+++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx
@@ -24,113 +24,133 @@ import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { StocksPageVm } from "./StocksPage.vm";
import classes from "./StocksPage.module.css";
+import { useStocksPageViewVm } from "./StocksPageView.vm";
type StocksPageViewProps = { vm: StocksPageVm };
-const StocksPageView: FC = (props) => {
- const { vm } = props;
+
+const StocksPageViewResults: FC = (props) => {
const { t } = useTranslation();
- function renderResults() {
- if (vm.loading) {
- return (
-
-
-
-
-
-
-
- );
- }
- if (!vm.ready) return;
- if (vm.items.length > 0) {
- return (
-
-
- {t("stock.searchVariants")}
-
- {vm.items.map(stock => (
-
-
- {stock.name[0]}
-
-
-
- {stock.name}
-
-
- {stock.ticker}
- {" "}
- ·
- {" "}
- {t(`stock.shareTypes.${stock.shareType}`, {
- defaultValue: t("stock.share"),
- })}
-
-
-
- {t(`stock.exchanges.${stock.realExchange}`, {
- defaultValue: t("stock.exchangeUnknown"),
- })}
-
- {!!(stock.classCode) && ((
-
- {t("stock.classCode", { code: stock.classCode })}
-
- ))}
-
- {!!(stock.isin) && ((
-
- {t("stock.isin", { value: stock.isin })}
-
- ))}
- {(stock.apiTradeAvailable === false) && ((
-
- {t("stock.apiUnavailable")}
-
- ))}
-
-
-
- {stock.price ? formatMoney(stock.price) : "—"}
-
-
- {stock.price && stock.priceAt
- ? t("stock.priceAt", {
- date: formatSnapshotTime(stock.priceAt),
- })
- : t("stock.priceUnavailable")}
-
- {(stock.apiTradeAvailable === true) && ((
-
- {t("stock.apiAvailable")}
-
- ))}
-
-
- ))}
-
- );
- }
+ const { vm } = props;
+
+ if (vm.loading) {
return (
-
- {t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
-
+
+
+
+
+
+
+
+
+
);
}
+
+ if (!vm.ready) return;
+
+ if (vm.items.length > 0) {
+ return (
+
+
+ {t("stock.searchVariants")}
+
+
+ {vm.items.map(stock => (
+
+
+ {stock.name[0]}
+
+
+
+
+ {stock.name}
+
+
+
+ {stock.ticker} ·{" "}
+ {t(`stock.shareTypes.${stock.shareType}`, {
+ defaultValue: t("stock.share"),
+ })}
+
+
+
+
+ {t(`stock.exchanges.${stock.realExchange}`, {
+ defaultValue: t("stock.exchangeUnknown"),
+ })}
+
+
+ {!!stock.classCode && (
+
+ {t("stock.classCode", { code: stock.classCode })}
+
+ )}
+
+
+ {!!stock.isin && (
+
+ {t("stock.isin", { value: stock.isin })}
+
+ )}
+
+ {stock.apiTradeAvailable === false && (
+
+ {t("stock.apiUnavailable")}
+
+ )}
+
+
+
+
+ {stock.price ? formatMoney(stock.price) : "—"}
+
+
+
+ {stock.price && stock.priceAt
+ ? t("stock.priceAt", {
+ date: formatSnapshotTime(stock.priceAt),
+ })
+ : t("stock.priceUnavailable")}
+
+
+ {stock.apiTradeAvailable === true && (
+
+ {t("stock.apiAvailable")}
+
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {t(vm.partial ? "investment.partial" : "stock.noSearchResults")}
+
+ );
+};
+
+const StocksPageView: FC = (props) => {
+ const { onChange, t, vm } = useStocksPageViewVm(props);
+
return (
{t("stock.searchTitle")}
+
{t("stock.searchSubtitle")}
+
= (props) => {
>
{t("investment.refresh")}
- {!!(vm.partial) && ((
- {t("investment.partial")}
- ))}
+
+ {!!vm.partial && {t("investment.partial")}}
+
vm.setQuery(event.currentTarget.value)}
+ onChange={onChange}
placeholder={t("stock.searchPlaceholder")}
size="md"
value={vm.query}
/>
- {!(vm.canSearch) && ((
- {t("stock.searchHint")}
- ))}
- {!!(vm.error) && ((
+
+ {!vm.canSearch && {t("stock.searchHint")}}
+
+ {!!vm.error && (
@@ -163,16 +183,19 @@ const StocksPageView: FC = (props) => {
: "stock.sectionError",
)}
+
{t("accounts.openSettings")}
+
{t("accounts.retry")}
- ))}
- {renderResults()}
+ )}
+
+
);
};
diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
index a227d4d..8692be4 100644
--- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
+++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts
@@ -9,6 +9,7 @@ import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
type StocksPageVm = ReturnType;
+
function useStocksPageVm() {
const [parameters, setParameters] = useSearchParams();
const query = parameters.get("q") ?? "";
@@ -17,6 +18,7 @@ function useStocksPageVm() {
const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]);
const canSearch = query.trim().length >= MIN_STOCK_SEARCH_LENGTH;
const isWaiting = query.trim() !== debounced;
+
return {
canSearch,
code: apiErrorCode(result.error),
@@ -35,7 +37,10 @@ function useStocksPageVm() {
&& result.meta?.sync.state !== "failed")),
partial: result.meta?.availability === "partial",
query,
- ready: debounced.length >= MIN_STOCK_SEARCH_LENGTH && !isWaiting && Boolean(result.data),
+ ready:
+ debounced.length >= MIN_STOCK_SEARCH_LENGTH
+ && !isWaiting
+ && Boolean(result.data),
refreshing: sync.refreshing,
retry: () => {
void sync.retry();
diff --git a/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts
new file mode 100644
index 0000000..5fe7be9
--- /dev/null
+++ b/src/pages/stocks/ui/StocksPage/StocksPageView.vm.ts
@@ -0,0 +1,20 @@
+import type { ComponentProps } from "react";
+
+import { TextInput } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+import type { StocksPageVm } from "./StocksPage.vm";
+
+type StocksPageViewProps = { vm: StocksPageVm };
+
+function useStocksPageViewVm(props: StocksPageViewProps) {
+ const { vm } = props;
+ const { t } = useTranslation();
+
+ const onChange: ComponentProps["onChange"] = event =>
+ vm.setQuery(event.currentTarget.value);
+
+ return { onChange, t, vm };
+}
+
+export { useStocksPageViewVm };
diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx
index 7b409bb..3af7a5f 100644
--- a/src/shared/api/authenticated-query.test.tsx
+++ b/src/shared/api/authenticated-query.test.tsx
@@ -16,6 +16,7 @@ const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
);
+
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
@@ -29,6 +30,7 @@ describe("useAuthenticatedQuery", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
+
return HttpResponse.json(
resource([
{
diff --git a/src/shared/api/authenticated-query.ts b/src/shared/api/authenticated-query.ts
index b006b68..bbe0a09 100644
--- a/src/shared/api/authenticated-query.ts
+++ b/src/shared/api/authenticated-query.ts
@@ -16,6 +16,7 @@ function useAuthenticatedQueries(
queries: UseQueryOptions[],
) {
const hasSession = useHasAuthenticatedSession();
+
return useQueries({
queries: queries.map(
(options): UseQueryOptions => ({
diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts
index 9c61329..ac80480 100644
--- a/src/shared/api/client.test.ts
+++ b/src/shared/api/client.test.ts
@@ -9,14 +9,18 @@ import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => {
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
+
const refresh = vi.fn(() => {
token = "fresh";
+
return Promise.resolve(true);
});
+
const authorization: string[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization.push(request.headers.get("Authorization") ?? "");
+
return authorization.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json(resource([]));
@@ -50,10 +54,14 @@ describe("apiClient authentication", () => {
});
it("does not attach Bearer auth to authentication endpoints", async () => {
- let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
+ let authorization: null | string
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ = null;
+
server.use(
http.post("*/api/v1/auth/refresh", ({ request }) => {
authorization = request.headers.get("Authorization");
+
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}),
);
@@ -71,12 +79,15 @@ describe("apiClient authentication", () => {
let generation = 0;
let calls = 0;
- const { promise: pending, resolve: release } = Promise.withResolvers();
+ const { promise: pending, resolve: release }
+ = Promise.withResolvers();
+
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
if (++calls === 1)
return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
await pending;
+
return HttpResponse.json(resource([{ id: "previous-user-account" }]));
}),
);
@@ -86,10 +97,12 @@ describe("apiClient authentication", () => {
generation: () => generation,
refresh: () => Promise.resolve(true),
});
+
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const response = expect(
apiClient.GET("/api/v1/brokerage-accounts"),
).rejects.toMatchObject({ name: "AbortError" });
+
await vi.waitFor(() => expect(calls).toBe(2));
generation += 1;
release();
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
index 7b9555a..3d50171 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client.ts
@@ -8,13 +8,16 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
+
const apiBaseUrl
= typeof configuredBaseUrl === "string"
? configuredBaseUrl
: "http://localhost:3000";
type AuthHandlers = {
- accessToken: () => null | string | undefined;
+ accessToken: () => null
+ | string
+ | undefined;
clear: () => void;
generation?: () => number;
refresh: () => Promise;
@@ -22,12 +25,16 @@ type AuthHandlers = {
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
const latestReads = new Map();
+
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
for (const request of inFlight)
if (isMatches(request.pathname)) request.invalidated = true;
}
-const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false };
+const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
+ hasSession: false,
+};
+
const authSessionListeners = new Set<() => void>();
async function authenticatedFetch(request: Request): Promise {
@@ -36,17 +43,21 @@ async function authenticatedFetch(request: Request): Promise {
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { invalidated: false, pathname: url.pathname };
+
const read
= request.method === "GET"
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
: undefined;
+
if (read) {
read.sequence += 1;
read.pending += 1;
latestReads.set(key, read);
}
+
const sequence = read?.sequence;
inFlight.add(ticket);
+
try {
return await fetchCurrent(
request,
@@ -72,47 +83,65 @@ async function fetchCurrent(
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
+
const session = sessionState.handlers;
const generation = session?.generation?.();
+
const isCurrent = () =>
(session?.generation
? generation === session.generation()
&& generation === sessionState.handlers?.generation?.()
: session === sessionState.handlers) && isLatest();
+
const token = session?.accessToken();
+
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
+
return new Request(source, { headers });
};
+
const authorized
= token && !isAuthRequest ? withToken(request, token) : request;
+
const retryRequest = authorized.clone();
const response = await fetch(authorized);
await response.clone().arrayBuffer();
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
- if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED)
+ if (
+ isAuthRequest
+ || !sessionState.handlers
+ || response.status !== HTTP_UNAUTHORIZED
+ )
return response;
let problem: unknown;
+
try {
problem = await response.clone().json();
}
catch {
return response;
}
- if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized)) return response;
+
+ if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized))
+ return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
+
if (!(await sessionState.handlers.refresh())) {
if (isCurrent()) sessionState.handlers.clear();
+
return response;
}
+
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
+
return retried;
}
@@ -128,6 +157,7 @@ function setAuthenticatedSession(hasSession: boolean) {
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
+
return () => authSessionListeners.delete(listener);
}
diff --git a/src/shared/api/problem-details.test.ts b/src/shared/api/problem-details.test.ts
index 6170ea8..0c594e3 100644
--- a/src/shared/api/problem-details.test.ts
+++ b/src/shared/api/problem-details.test.ts
@@ -3,7 +3,12 @@ import { expect, it } from "vitest";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
- expect(isApiErrorCode(apiErrorCode({ code: "UNAUTHORIZED" }), ApiErrorCode.Unauthorized)).toBe(true);
+ expect(
+ isApiErrorCode(
+ apiErrorCode({ code: "UNAUTHORIZED" }),
+ ApiErrorCode.Unauthorized,
+ ),
+ ).toBe(true);
const unknownCode = apiErrorCode({ code: "FUTURE_PROVIDER_ERROR" });
expect(unknownCode).toBe("FUTURE_PROVIDER_ERROR");
expect(isApiErrorCode(unknownCode, ApiErrorCode.Unauthorized)).toBe(false);
diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts
index 31d37ba..388da96 100644
--- a/src/shared/api/resource-query.ts
+++ b/src/shared/api/resource-query.ts
@@ -1,9 +1,11 @@
-import { type QueryClient,
+import {
+ type QueryClient,
type QueryFunctionContext,
type QueryKey,
useQueryClient,
type UseQueryOptions,
- type UseQueryResult } from "@tanstack/react-query";
+ type UseQueryResult,
+} from "@tanstack/react-query";
import { useEffect } from "react";
import type { components } from "./generated/schema";
@@ -18,6 +20,7 @@ type Resource = {
data: unknown;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
+
function invalidateResourceAccess(
client: QueryClient,
group: string,
@@ -36,10 +39,12 @@ function invalidateResourceAccess(
const epochs = accessEpochs.get(client) ?? new Map();
accessEpochs.set(client, epochs);
epochs.set(group, (epochs.get(group) ?? 0) + 1);
+
const affected = {
predicate: (cached: { meta?: Record }) =>
cached.meta?.accessGroup === group,
};
+
void client.cancelQueries(affected);
for (const cached of client.getQueryCache().findAll(affected))
cached.setState({
@@ -57,9 +62,11 @@ function unwrapResourceResult(
const isMissing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
+
const status: UseQueryResult["status"] = isMissing
? "pending"
: result.status;
+
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
@@ -81,17 +88,22 @@ function useResourceQuery(
options: UseQueryOptions,
) {
const client = useQueryClient();
+
const group
= typeof options.meta?.accessGroup === "string"
? options.meta.accessGroup
: undefined;
+
let epochs = accessEpochs.get(client);
+
if (!epochs) {
epochs = new Map();
accessEpochs.set(client, epochs);
}
+
const groupEpochs = epochs;
const queryFunction = options.queryFn;
+
const guarded
= typeof queryFunction === "function"
? async (context: QueryFunctionContext) => {
@@ -99,13 +111,16 @@ function useResourceQuery(
const result = await queryFunction(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
+
return result;
}
: queryFunction;
+
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
useEffect(() => {
if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]);
+
const observe = async (expectedStateVersion?: string) => {
const queryFunction = guarded;
if (
@@ -116,13 +131,16 @@ function useResourceQuery(
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
+
return client.query({
...options,
queryFn: async (context: QueryFunctionContext) => {
const [method, path, input] = context.queryKey;
+
const init = input as
- | undefined
+ undefined
| { params?: { query?: Record } };
+
const queryKey = [
method,
path,
@@ -134,7 +152,9 @@ function useResourceQuery(
},
},
] as unknown as K;
+
const response = await queryFunction({ ...context, queryKey });
+
if (
expectedStateVersion
&& response.meta.stateVersion !== expectedStateVersion
@@ -143,8 +163,10 @@ function useResourceQuery(
const current = await queryFunction({ ...context, queryKey });
if (current.meta.stateVersion !== expectedStateVersion)
throw new DOMException("Resource changed", "AbortError");
+
return current;
}
+
return response;
},
retry: false,
@@ -153,6 +175,7 @@ function useResourceQuery(
staleTime: 0,
});
};
+
return {
...unwrapResourceResult(query),
observe,
diff --git a/src/shared/lib/calendar.schema.ts b/src/shared/lib/calendar.schema.ts
new file mode 100644
index 0000000..f95718f
--- /dev/null
+++ b/src/shared/lib/calendar.schema.ts
@@ -0,0 +1,3 @@
+import { z } from "zod";
+const calendarDate = z.iso.date();
+export { calendarDate };
diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts
new file mode 100644
index 0000000..997b5c8
--- /dev/null
+++ b/src/shared/lib/calendar.ts
@@ -0,0 +1,12 @@
+import { calendarDate } from "./calendar.schema";
+
+function isValidPeriod(from: null | string, to: null | string) {
+ return (
+ [from, to].every(
+ date => date === null || calendarDate.safeParse(date).success,
+ )
+ && (!from || !to || from <= to)
+ );
+}
+
+export { isValidPeriod };
diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts
index 720034d..826d772 100644
--- a/src/shared/lib/display.test.ts
+++ b/src/shared/lib/display.test.ts
@@ -22,11 +22,18 @@ describe("shared display helpers", () => {
);
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD");
- expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe("0 001 234,50 ₽");
- expect(formatMoney({ currency: "RUB", value: "123456789012345678901234567890.01" })).toBe(
- "123 456 789 012 345 678 901 234 567 890,01 ₽",
+ expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe(
+ "0 001 234,50 ₽",
);
- expect(formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true })).toBe("1,50 ₽");
+ expect(
+ formatMoney({
+ currency: "RUB",
+ value: "123456789012345678901234567890.01",
+ }),
+ ).toBe("123 456 789 012 345 678 901 234 567 890,01 ₽");
+ expect(
+ formatMoney({ currency: "RUB", value: "1.5" }, { withZeroCents: true }),
+ ).toBe("1,50 ₽");
expect(formatMoney(null)).toBe("—");
});
diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts
index 2464f56..0295165 100644
--- a/src/shared/lib/display.ts
+++ b/src/shared/lib/display.ts
@@ -4,6 +4,7 @@ const CENTS_PRECISION = 2;
type FormatMoneyOptions = {
withZeroCents?: boolean;
};
+
type Money = { currency: string; value: string };
function formatDate(value: string): string {
@@ -21,23 +22,40 @@ function formatDecimal(value: string): string {
const digits = sign ? integer.slice(1) : integer;
const groupSize = 3;
const groups: string[] = [];
+
for (let end = digits.length; end > 0; end -= groupSize) {
groups.push(digits.slice(Math.max(0, end - groupSize), end));
}
+
const grouped = groups.toReversed().join(" ");
const suffix = fraction ? `,${fraction}` : "";
+
return `${sign}${grouped}${suffix}`;
}
function formatMoney(
- money: Money | null | undefined,
+ money: Money
+ | null
+ | undefined,
{ withZeroCents = false }: FormatMoneyOptions = {},
): string {
if (!money) return "—";
- const [integerPart, fractionPart = ""] = money.value.split(".", DECIMAL_PART_COUNT);
- const normalizedFraction = withZeroCents ? fractionPart.padEnd(CENTS_PRECISION, "0") : fractionPart;
- const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart;
+
+ const [integerPart, fractionPart = ""] = money.value.split(
+ ".",
+ DECIMAL_PART_COUNT,
+ );
+
+ const normalizedFraction = withZeroCents
+ ? fractionPart.padEnd(CENTS_PRECISION, "0")
+ : fractionPart;
+
+ const decimal = normalizedFraction
+ ? `${integerPart}.${normalizedFraction}`
+ : integerPart;
+
const currency = money.currency === "RUB" ? "₽" : money.currency;
+
return `${formatDecimal(decimal)} ${currency}`;
}
@@ -51,14 +69,22 @@ function formatSnapshotTime(value: string): string {
const logoSize = 160;
-function instrumentLogoUrl(logoName: null | string | undefined): null | string {
+function formatChartValue(value: number) {
+ return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format(
+ value,
+ );
+}
+
+function instrumentLogoUrl(logoName?: null | string): null | string {
const stem = logoName?.trim().replace(/\.png$/i, "");
+
return stem
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
- : /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null;
+ : null; // eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data.
}
export {
+ formatChartValue,
formatDate,
formatDecimal,
formatMoney,
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 5e99c07..d490133 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,8 +1,11 @@
-export { bindIconAnimation } from "./bind-icon-animation";
+export { isValidPeriod } from "./calendar";
+export { calendarDate } from "./calendar.schema";
export {
+ formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
+export { useImageFallback } from "./useImageFallback";
diff --git a/src/shared/lib/useImageFallback.ts b/src/shared/lib/useImageFallback.ts
new file mode 100644
index 0000000..2b14d39
--- /dev/null
+++ b/src/shared/lib/useImageFallback.ts
@@ -0,0 +1,10 @@
+import { useState } from "react";
+
+function useImageFallback() {
+ const [failed, setFailed] = useState(false);
+ const onError = () => setFailed(true);
+
+ return { failed, onError };
+}
+
+export { useImageFallback };
diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx
index 831de16..4419c3d 100644
--- a/src/shared/theme/Foundations.stories.tsx
+++ b/src/shared/theme/Foundations.stories.tsx
@@ -16,53 +16,74 @@ import { colorRoles, designTokens } from "@wealthledger/ui/theme";
const ColorsView: FC = () => {
const theme = useMantineTheme();
+
return (
Цвета и семантические роли
+
Светлая схема WealthLedger. Источник цветов и ролей —
@wealthledger/tokens/tokens.json. Цвет не заменяет подпись состояния.
+
{Object.entries(designTokens.color.roles).map(([role, token]) => (
+
{role}
+
--wl-color-
{role}
+
- {/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role]}
- {" "}
- ·
- {" "}
- {parseThemeColor({ color: /* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ colorRoles[role], theme }).value}
+ {
+ // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
+ colorRoles[role]
+ }{" "}
+ ·{" "}
+ {
+ parseThemeColor({
+ color:
+ // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
+ colorRoles[role],
+ theme,
+ }).value
+ }
+
{token.description}
))}
+
{Object.keys(designTokens.color.palettes).map(palette => (
{palette}
+
- {/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.colors[palette].map((value, shade) => (
-
-
-
- {palette}
- .
- {shade}
-
- {value}
-
- ))}
+ {
+ // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
+ theme.colors[palette].map((value, shade) => (
+
+
+
+
+ {palette}.{shade}
+
+
+ {value}
+
+ ))
+ }
))}
+
Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow —
статусы; orange — дополнительный акцент. Grape — цвет графиков, его
@@ -75,23 +96,20 @@ const ColorsView: FC = () => {
const TypographyView: FC = () => {
const theme = useMantineTheme();
+
return (
Типографика
-
- Основной шрифт:
- {" "}
- {theme.fontFamily}
-
-
- Моноширинный:
- {" "}
- {theme.fontFamilyMonospace}
-
+
+ Основной шрифт: {theme.fontFamily}
+
+ Моноширинный: {theme.fontFamilyMonospace}
+
Title задаёт смысловую иерархию; выбирайте order по структуре страницы.
Text — основной текст, size sm/xs — подписи; mono — идентификаторы.
+
{Object.entries(theme.headings.sizes).map(([name, value]) => (
{
fz={value.fontSize}
lh={value.lineHeight}
>
- {name}
- {" "}
- · Заголовок WealthLedger
+ {name} · Заголовок WealthLedger
+
- {value.fontSize}
- {" "}
- /
- {" "}
- {value.lineHeight}
- {" "}
- · weight
- {" "}
+ {value.fontSize} / {value.lineHeight} · weight{" "}
{theme.headings.fontWeight}
))}
+
{Object.entries(theme.fontSizes).map(([size, value]) => (
-
- Размер
- {" "}
- {size}
- {" "}
- · Пример основного текста
-
+ Размер {size} · Пример основного текста
+
- {value}
- {" "}
- /
- {" "}
- {/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ theme.lineHeights[size]}
+ {value} /{" "}
+ {
+ // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
+ theme.lineHeights[size]
+ }
))}
+
- Веса:
- {" "}
+ Веса:{" "}
{Object.entries(theme.fontWeights)
.map(([name, value]) => `${name} ${value}`)
.join("; ")}
@@ -147,66 +153,63 @@ const TypographyView: FC = () => {
const LayoutView: FC = () => {
const theme = useMantineTheme();
+
return (
Интервалы, форма и адаптивность
+
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
Шкалы принадлежат tokens.json; адаптер Mantine сохраняет
масштабирование. Default radius:
- {theme.defaultRadius}
- ; scale:
- {" "}
- {theme.scale}
- . Размеры конкретных карточек
+ {theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
и их особые тени остаются локальными.
+
Spacing
+
{Object.entries(theme.spacing).map(([name, value]) => (
- {name}
- {" "}
- ·
- {" "}
- {value}
+ {name} · {value}
+
))}
+
Radius
+
{Object.entries(theme.radius).map(([name, value]) => (
- {name}
- {" "}
- ·
- {" "}
- {value}
+ {name} · {value}
))}
+
Shadows
+
{Object.entries(theme.shadows).map(([name, value]) => (
{name}
+
{value}
))}
+
Breakpoints
+
Именованные пороги responsive props. Узкий экран — base; не фиксируйте
ширину контента по размеру desktop.
+
{Object.entries(theme.breakpoints).map(([name, value]) => (
- {name}
- {" "}
- ·
- {" "}
- {value}
+ {name} · {value}
))}
@@ -228,7 +231,9 @@ const meta = {
} satisfies Meta;
export default meta;
+
type Story = StoryObj;
+
export const Colors: Story = { name: "Цвета", render: () => };
export const SecondaryText: Story = {
name: "Вторичный текст",
@@ -238,17 +243,20 @@ export const SecondaryText: Story = {
"var(--wl-color-surface)",
"var(--wl-color-canvas)",
"var(--wl-color-subtle)",
- ...Object.keys(designTokens.color.palettes).flatMap(palette => palette === "dark"
- ? []
- : [
- `var(--mantine-color-${palette}-0)`,
- `color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
- ]),
+ ...Object.keys(designTokens.color.palettes).flatMap(palette =>
+ palette === "dark"
+ ? []
+ : [
+ `var(--mantine-color-${palette}-0)`,
+ `color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
+ ],
+ ),
].map(background => (
22.09.2026 · Синтетические данные
+
Не удалось обновить данные. Сохранён предыдущий результат.
diff --git a/src/shared/theme/contrast.test.ts b/src/shared/theme/contrast.test.ts
index e70c9ab..c3f3891 100644
--- a/src/shared/theme/contrast.test.ts
+++ b/src/shared/theme/contrast.test.ts
@@ -7,16 +7,23 @@ function luminance(color: string) {
.map(value =>
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
);
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
+
describe("semantic text contrast", () => {
it.each(["positive", "negative", "muted"] as const)(
"keeps %s readable on canvas, white, subtle and tinted surfaces",
(tone) => {
- const role = /* eslint-disable-line security/detect-object-injection -- Keys come from the repository’s trusted design-token definitions. */ tokens.color.roles[tone];
+ const role
+ // eslint-disable-next-line security/detect-object-injection -- Keys come from the repository’s trusted design-token definitions.
+ = tokens.color.roles[tone];
+
const palettes = tokens.color.palettes;
+
const foreground
= palettes[role.color as keyof typeof palettes][role.shade];
+
const surfaces = [
"#ffffff",
palettes.gray[0],
@@ -41,6 +48,7 @@ describe("semantic text contrast", () => {
.join("")}`,
),
];
+
for (const background of surfaces)
expect(
(luminance(background) + 0.05) / (luminance(foreground) + 0.05),
diff --git a/src/shared/ui/Button.stories.tsx b/src/shared/ui/Button.stories.tsx
index 0a68518..d569b04 100644
--- a/src/shared/ui/Button.stories.tsx
+++ b/src/shared/ui/Button.stories.tsx
@@ -34,13 +34,16 @@ const meta = {
} satisfies Meta;
export default meta;
+
type Story = StoryObj;
+
export const Primary: Story = {
name: "Основное действие",
play: async ({ args, canvasElement }) => {
const button = within(canvasElement).getByRole("button", {
name: typeof args.children === "string" ? args.children : undefined,
});
+
button.focus();
await expect(button).toHaveFocus();
await userEvent.keyboard("{Enter}");
diff --git a/src/test/handlers.ts b/src/test/handlers.ts
index ec28cd0..82f3a3f 100644
--- a/src/test/handlers.ts
+++ b/src/test/handlers.ts
@@ -19,9 +19,11 @@ const handlers = [
),
http.get("*/api/v1/auth/me", () => HttpResponse.json(authentication.user)),
http.get("*/api/v1/tbank-connection", () =>
- HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
- { connected: false, connectedAt: null }
- /* eslint-enable unicorn/no-null */)),
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ HttpResponse.json(
+ { connected: false, connectedAt: null },
+ /* eslint-enable unicorn/no-null */
+ ),
),
http.put("*/api/v1/tbank-connection", () =>
HttpResponse.json({ connected: true, connectedAt: "2026-08-14T00:00:00Z" }),
@@ -47,15 +49,17 @@ const handlers = [
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(
- resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ resource(
{
accountId: "account-1",
hasNext: false,
items: [],
nextCursor: null,
observedAt: "2026-08-14T00:00:00Z",
- }
- /* eslint-enable unicorn/no-null */)),
+ },
+ /* eslint-enable unicorn/no-null */
+ ),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
diff --git a/src/test/resource.ts b/src/test/resource.ts
index 63c6fcc..227d73b 100644
--- a/src/test/resource.ts
+++ b/src/test/resource.ts
@@ -2,8 +2,10 @@ import type { paths } from "@/shared/api";
type Meta
= paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"];
+
function resource(data: T, meta: Partial = {}) {
- return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
+ return (
{
data,
meta: {
@@ -17,7 +19,8 @@ function resource(data: T, meta: Partial = {}) {
...meta,
} satisfies Meta,
}
- /* eslint-enable unicorn/no-null */);
+ /* eslint-enable unicorn/no-null */
+ );
}
export { resource };
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 88ecc60..24b5009 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -39,7 +39,9 @@ Object.defineProperty(globalThis, "matchMedia", {
dispatchEvent: () => false,
matches: false,
media: query,
- onchange: /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null,
+ onchange:
+ // eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
+ null,
removeEventListener: () => {},
removeListener: () => {},
}),
diff --git a/src/test/storybook.test.tsx b/src/test/storybook.test.tsx
index d7b7ec6..2feed41 100644
--- a/src/test/storybook.test.tsx
+++ b/src/test/storybook.test.tsx
@@ -28,6 +28,7 @@ describe("Storybook isolated product examples", () => {
vi.spyOn(Document.prototype, "cookie", "get"),
vi.spyOn(Document.prototype, "cookie", "set"),
];
+
const stories = [
...Object.values(composeStories(AccountStories, preview)),
...Object.values(composeStories(LoadingStories, preview)),
@@ -39,16 +40,20 @@ describe("Storybook isolated product examples", () => {
...Object.values(composeStories(IncomePeriodStories, preview)),
...Object.values(composeStories(OperationStories, preview)),
];
+
for (const Story of stories) {
render();
for (const button of screen.queryAllByRole("button"))
fireEvent.click(button);
for (const link of screen.queryAllByRole("link")) fireEvent.click(link);
+
for (const image of screen.queryAllByRole("img")) {
expect(image).not.toHaveAttribute("src");
}
+
cleanup();
}
+
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
});