Compare commits

...
6 Commits
Author SHA1 Message Date
ksv741 6a89b08ee5 chore: guard presentation data boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 10m18s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 59s
Frontend verify / verify (push) Successful in 10m53s
2026-10-01 20:53:23 +03:00
ksv741 2844b22dba refactor: simplify finance view models 2026-10-01 20:53:13 +03:00
ksv741 ff67a768ad refactor: unify bank form types and actions 2026-10-01 20:53:00 +03:00
ksv741 10baefda79 fix(deps): resolve npm audit advisories
Refresh vulnerable transitive patch versions.
Scope patched undici and basic-ftp to release-it.
Retain the low audit threshold and release tooling version.
2026-10-01 20:51:32 +03:00
ksv741 f5f4447d60 fix: sort stock and bond events by payment date 2026-09-30 07:40:43 +03:00
ksv741 0bc8bc36b7 refactor: share source sync status presentation 2026-09-30 07:40:23 +03:00
35 changed files with 534 additions and 234 deletions
+8
View File
@@ -17,6 +17,10 @@
CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
Review проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
+6
View File
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
type imports, error helpers, router components и form context разрешены.
Транзитивный fetching через local presentation hook проверяется review;
Storybook дополнительно проверяет reachable graph. Новое исключение должно
сохранять presentation-only responsibility.
JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
+38
View File
@@ -185,6 +185,44 @@ export default defineConfig(
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
},
{
files: ["src/**/*.view.tsx"],
rules: {
"no-restricted-imports": ["error", {
paths: [
{
allowTypeImports: true,
message: "Query lifecycle belongs to the data VM.",
name: "@tanstack/react-query",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-fetch",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-react-query",
},
],
patterns: [
{
allowTypeImports: true,
group: ["react-router", "@/entities/*", "@/features/*"],
importNamePattern: "^use",
message: "Pass route, auth and entity data from the data VM.",
},
{
allowTypeImports: true,
group: ["@/shared/api", "@/shared/api/*"],
importNamePattern: "^use|^api(?:Query)?Client$",
message: "Fetching belongs to the data VM; error helpers are allowed.",
},
],
}],
},
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
+21 -21
View File
@@ -4082,9 +4082,9 @@
"license": "MIT"
},
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6942,9 +6942,9 @@
}
},
"node_modules/basic-ftp": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6969,9 +6969,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9668,9 +9668,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"devOptional": true,
"funding": [
{
@@ -10702,9 +10702,9 @@
}
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14291,9 +14291,9 @@
}
},
"node_modules/release-it/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14673,9 +14673,9 @@
}
},
"node_modules/serialize-javascript": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz",
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
"integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+6
View File
@@ -114,6 +114,12 @@
"vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0"
},
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
},
"allowScripts": {
"esbuild@0.28.2": true,
"fsevents@2.3.3": true
+40
View File
@@ -338,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
assert.equal(again.errorCount, 0);
});
test("views accept presentation inputs but reject direct data hooks", async () => {
const eslint = new ESLint();
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
for (const code of [
"import { useStock } from \"@/entities/stock\";",
"import { useAuth } from \"@/features/auth\";",
"import { useNavigate } from \"react-router\";",
"import { apiClient } from \"@/shared/api\";",
"import { useQuery } from \"@tanstack/react-query\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.some(message =>
message.ruleId === "no-restricted-imports"));
}
for (const code of [
"import { Link } from \"react-router\";",
"import { apiErrorCode } from \"@/shared/api\";",
"import type { StockPosition } from \"@/entities/stock\";",
"import type { QueryKey } from \"@tanstack/react-query\";",
"import { useTranslation } from \"react-i18next\";",
"import { useFormContext } from \"react-hook-form\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
assert.ok(result.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
}
const [vm] = await eslint.lintText(
"import { useStock } from \"@/entities/stock\";",
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
);
assert.ok(vm.messages.every(message =>
message.ruleId !== "no-restricted-imports"));
});
test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({
name: z
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
});
}
export { categorySchema };
export { categorySchema, type CategoryValues };
@@ -12,16 +12,14 @@ import { type FC } from "react";
import { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import { useCategoryManagerVm } from "./CategoryManager.vm";
type CategoryManagerProps = {
onClose: () => void;
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
import {
type CategoryManagerProps,
useCategoryManagerVm,
} from "./CategoryManager.vm";
const CategoryFields: FC = () => {
const { t } = useTranslation();
@@ -12,7 +12,7 @@ import {
useUpdateExpenseCategory,
} from "../../api/mutations";
import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema";
import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = {
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => {
setError(undefined);
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
if (result) props.onCreated?.(result.id);
}
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
finishEditing();
}
catch (error_) {
setError(error_);
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
};
const onCancel = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
finishEditing();
setError(undefined);
};
@@ -131,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
};
}
export { useCategoryManagerVm };
export { type CategoryManagerProps, useCategoryManagerVm };
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import { formatDate, formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display";
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
);
};
type BrokerageAccountSourceStatusProps = {
source: BrokerageAccountCard["source"];
};
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props,
) => {
const { source } = props;
const { t } = useTranslation();
const isSyncing
= source?.sync.state === "running" || source?.sync.state === "queued";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
{isSyncing && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation();
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<BrokerageAccountSummary summary={summary} />
{!!source?.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
)}
{(source?.sync.state === "running"
|| source?.sync.state === "queued") && (
<Text role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
<BrokerageAccountSourceStatus source={source} />
</Stack>
{!!positionCounts && (
@@ -5,7 +5,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
@@ -33,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{events.state === "loaded" && (
<>
{!!events.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(events.lastSuccessfulAt),
})}
</Text>
)}
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{events.state === "loading" && (
+1
View File
@@ -1,3 +1,4 @@
export { toInstrumentEventRows } from "./model/operation-rows";
export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { toInstrumentEventRows } from "./operation-rows";
const operation: BrokerageOperation = {
accruedInterest: null,
category: "trade",
commission: null,
description: "Покупка",
figi: null,
id: "first",
instrumentType: "stock",
instrumentUid: "uid",
logoName: null,
occurredAt: "2026-01-01T00:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: null,
type: "OPERATION_TYPE_BUY",
};
it("fills only missing identity without changing rows or their order", () => {
const items = [operation, {
...operation,
id: "second",
logoName: "",
ticker: "own",
}];
const original = structuredClone(items);
expect(toInstrumentEventRows(items, {
logoName: "fallback.png",
ticker: "fallback",
})).toEqual([
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
items[1],
]);
expect(items).toEqual(original);
expect(toInstrumentEventRows(items)).toEqual(items);
expect(toInstrumentEventRows()).toEqual([]);
});
@@ -0,0 +1,14 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
function toInstrumentEventRows(
items: BrokerageOperation[] = [],
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
): BrokerageOperation[] {
return items.map(item => ({
...item,
logoName: item.logoName ?? instrument?.logoName ?? null,
ticker: item.ticker ?? instrument?.ticker ?? null,
}));
}
export { toInstrumentEventRows };
@@ -13,7 +13,7 @@ import {
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section";
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls}
</Group>
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
<SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.state.lastSuccessfulAt),
})}
</Text>
)}
{!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
type InvestmentRefreshStateProps = {
labelScope?: "accounts" | "portfolio";
@@ -20,13 +20,7 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
return (
<>
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Button
disabled={refreshing}
@@ -16,7 +16,7 @@ import {
BrokerageEventCard,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,13 +55,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
return (
<Stack gap="lg">
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("events.title")}</Title>
@@ -13,7 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
@@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
return (
<Stack className={classes.page} gap="lg">
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("income.title")}</Title>
@@ -7,7 +7,6 @@ import {
MultiSelect,
Progress,
Stack,
Text,
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
@@ -15,7 +14,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -63,13 +62,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack gap="lg">
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("ledger.title")}</Title>
@@ -7,7 +7,8 @@ import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
type IncomeSummaryProps
= | {
@@ -25,6 +26,12 @@ type IncomeSummaryProps
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial")
return (
<Text c="dimmed" role="status">
@@ -59,17 +66,11 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.refreshError && <Text role="status">{t("income.refreshError")}</Text>}
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={props.lastSuccessfulAt}
stale={props.stale}
/>
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,22 +18,19 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { t } = useTranslation();
const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{operations.state === "loaded" && (
<>
{!!operations.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(operations.lastSuccessfulAt),
})}
</Text>
)}
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!operations.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={operations.lastSuccessfulAt}
stale={operations.stale}
/>
)}
{operations.state === "loading" && (
@@ -203,6 +203,37 @@ describe("AccountsPage", () => {
});
});
it.each(["idle", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)",
async (state) => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
availability: "partial",
sync: { errorCode: null, id: null, state },
})),
),
);
const client = createTestQueryClient();
renderPage(client);
await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [],
meta: { availability: "partial" },
}),
);
expect(screen.queryByText("Нет инвестиционных счетов"))
.not.toBeInTheDocument();
if (state === "failed")
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(await screen.findByRole("button", { name: "Повторить" }))
.toBeVisible();
else
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
expect(screen.getByRole("status")).toBeVisible();
},
);
it("keeps unknown membership loading instead of declaring no accounts", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
@@ -15,31 +15,31 @@ function useAccountsPageViewModel() {
) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const };
if (accounts.isError || accounts.meta?.sync.state === "failed")
return {
code: apiErrorCode(accounts.error),
retry: sync.retry,
state: "error" as const,
};
return {
code: apiErrorCode(accounts.error),
retry: sync.retry,
state: "error" as const,
};
}
if (!accounts.data?.length)
return {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
const refresh = {
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
};
if (accounts.data.length === 0)
return {
...refresh,
state: "empty" as const,
};
return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
...refresh,
state: "loaded" as const,
};
}
+9 -22
View File
@@ -9,10 +9,14 @@ import {
useBondQuote,
} from "@/entities/bond";
import { useBrokerageOperationHistory } from "@/entities/broker-operation";
import { section, useInstrumentParameters } from "@/features/instrument-details";
import {
section,
toInstrumentEventRows,
useInstrumentParameters,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
type BondPageVm = ReturnType<typeof useBondPageVm>;
@@ -62,20 +66,7 @@ function useBondPageVm() {
code: apiErrorCode(bond.error),
coupons: coupons.data,
couponsState: section(coupons, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? bond.data?.logoName
?? null,
ticker:
item.ticker
?? bond.data?.ticker
?? null,
})) ?? [],
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName),
@@ -92,9 +83,7 @@ function useBondPageVm() {
coupons.data?.coupons
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
compareDateDesc(a.paymentDate, b.paymentDate),
) ?? [],
period,
position: position.data,
@@ -110,9 +99,7 @@ function useBondPageVm() {
coupons.data?.coupons
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
compareDateDesc(b.paymentDate, a.paymentDate),
) ?? [],
};
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { BankCard } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema";
const card: BankCard = {
archivedAt: null,
balance: { currency: "RUB", value: "0" },
bankName: null,
id: "card",
lastFour: null,
name: "Карта",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
};
const values: OperationValues = {
amount: "1",
cardId: card.id,
categoryId: "",
date: "2026-01-02",
description: "",
kind: "expense",
originalExpenseId: "",
targetCardId: "",
};
describe("expense category validation", () => {
it.each([
["", [{ id: "default", isDefault: true }], true],
["chosen", [{ id: "chosen", isDefault: false }], true],
["", [{ id: "chosen", isDefault: false }], false],
["unknown", [{ id: "default", isDefault: true }], false],
["", undefined, false],
] as const)("checks category %s with current categories %j", (
categoryId, categories, valid,
) => {
const schema = createOperationSchema({
cards: [card],
categories: categories ? [...categories] : undefined,
});
const result = schema.safeParse({ ...values, categoryId });
expect(result.success).toBe(valid);
if (!result.success)
// eslint-disable-next-line vitest/no-conditional-expect -- Invalid table cases retain the category field error boundary.
expect(result.error.issues.map(issue => issue.path))
.toEqual([["categoryId"]]);
});
});
@@ -10,8 +10,6 @@ import {
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode {
Custom = "custom",
@@ -43,6 +41,8 @@ const operationSchema = z.object({
targetCardId: z.string(),
});
type OperationValues = z.infer<typeof operationSchema>;
function createOperationSchema({
cards,
categories,
@@ -83,16 +83,19 @@ function createOperationSchema({
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund")
if (values.kind === "refund") {
validateRefund(values, context, expense, limit);
if (
values.kind === "expense"
&& !categories?.some(
c =>
c.id
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
)
)
return;
}
if (values.kind !== "expense") return;
const effectiveCategoryId
= values.categoryId || categories?.find(c => c.isDefault)?.id;
if (!categories?.some(c => c.id === effectiveCategoryId))
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
@@ -128,4 +131,4 @@ function validateRefund(
});
}
export { createOperationSchema };
export { createOperationSchema, type OperationValues };
@@ -4,6 +4,8 @@ import type {
BankOperation,
} from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.schema";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
@@ -26,20 +28,11 @@ type OperationFormProps = {
operation?: BankOperation;
};
type OperationValues = {
amount: string;
cardId: string;
categoryId: string;
date: string;
description: string;
kind: BankOperation["kind"];
originalExpenseId: string;
targetCardId: string;
};
export type {
Choice,
OperationFieldsProps,
OperationFormProps,
OperationValues,
};
export { type OperationValues } from "./OperationForm.schema";
@@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation();
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
}),
...(values.kind === "expense" && {
categoryId:
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
values.categoryId || defaultCategoryId,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
cards: options,
categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
defaultCategory: defaultCategoryId ?? "",
editing: Boolean(props.operation),
kind,
kinds: bankOperationKinds.map(value => ({
+5 -21
View File
@@ -11,11 +11,12 @@ import {
} from "@/entities/stock";
import {
section,
toInstrumentEventRows,
useInstrumentParameters,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
type StockPageVm = ReturnType<typeof useStockPageVm>;
@@ -65,20 +66,7 @@ function useStockPageVm() {
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
eventRows:
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? stock.data?.logoName
?? null,
ticker:
item.ticker
?? stock.data?.ticker
?? null,
})) ?? [],
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter,
hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName),
@@ -93,9 +81,7 @@ function useStockPageVm() {
dividends.data?.dividends
.filter(event => event.timing !== "upcoming")
.toSorted((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
compareDateDesc(a.paymentDate, b.paymentDate),
) ?? [],
period,
position: position.data,
@@ -113,9 +99,7 @@ function useStockPageVm() {
dividends.data?.dividends
.filter(event => event.timing === "upcoming")
.toSorted((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
compareDateDesc(b.paymentDate, a.paymentDate),
) ?? [],
};
}
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isValidPeriod, moscowDate } from "./calendar";
import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
import { calendarDate } from "./calendar.schema";
describe("calendar dates", () => {
@@ -32,4 +32,22 @@ describe("calendar dates", () => {
expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31");
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
});
it("compares dates newest first and keeps dateless rows last", () => {
const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"];
expect(dates.toSorted(compareDateDesc)).toEqual([
"2026-03-01",
"2026-02-01",
"2026-01-01",
null,
]);
expect(dates.toSorted((a, b) => compareDateDesc(b, a))).toEqual([
null,
"2026-01-01",
"2026-02-01",
"2026-03-01",
]);
});
});
+7 -1
View File
@@ -16,6 +16,12 @@ const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
});
// Newest first. Missing dates sort last in both directions, so a row without a
// date never jumps to the top of a "past" list.
function compareDateDesc(a: null | string, b: null | string) {
return (b ?? "").localeCompare(a ?? "");
}
function moscowDate(value: Date): string {
const parts = moscowFormatter.formatToParts(value);
@@ -25,4 +31,4 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`;
}
export { isValidPeriod, moscowDate };
export { compareDateDesc, isValidPeriod, moscowDate };
+1 -1
View File
@@ -1,4 +1,4 @@
export { isValidPeriod, moscowDate } from "./calendar";
export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema";
export {
formatChartValue,
@@ -0,0 +1,46 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { SourceSyncStatus, type SourceSyncStatusProps } from "./SourceSyncStatus";
function renderStatus(props: SourceSyncStatusProps) {
render(
<MantineProvider>
<SourceSyncStatus {...props} />
</MantineProvider>,
);
}
describe("SourceSyncStatus", () => {
it("renders nothing without source data", () => {
renderStatus({});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByText(/Данные/)).not.toBeInTheDocument();
});
it("renders the source timestamp", () => {
renderStatus({ lastSuccessfulAt: "2026-02-03T10:15:00.000Z" });
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
});
it("announces the caller-provided error text as a status", () => {
renderStatus({ errorText: "Обновление не удалось" });
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
});
it("keeps the notices next to the timestamp they belong to", () => {
renderStatus({
errorText: "Обновление не удалось",
lastSuccessfulAt: "2026-02-03T10:15:00.000Z",
stale: true,
});
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
});
});
@@ -0,0 +1,37 @@
import type { FC } from "react";
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
// Owns the "when did the source last answer" line and the two notices that
// always travel with it. Surfaces keep their own busy indicator: a list card
// shows a muted line, a page header a progress bar — that is a product call.
type SourceSyncStatusProps = {
errorText?: null | string;
lastSuccessfulAt?: null | string;
stale?: boolean;
};
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
const { t } = useTranslation();
return (
<>
{!!props.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.errorText && <Text role="status">{props.errorText}</Text>}
</>
);
};
export { SourceSyncStatus, type SourceSyncStatusProps };
+1
View File
@@ -0,0 +1 @@
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";