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 CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: ESLint и review ## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
Review проверяет то, чего синтаксис не доказывает: Review проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. - Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`. - Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток - Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
+6
View File
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
в каталоге: её граф зависимостей не достигает application data/API. в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view. `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, JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай `dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием `useController` в VM вместо inline `Controller.render`. Значение prop с условием
+38
View File
@@ -185,6 +185,44 @@ export default defineConfig(
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], 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}"], files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
+21 -21
View File
@@ -4082,9 +4082,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": { "node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
"version": "2.1.4", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6942,9 +6942,9 @@
} }
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.3.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6969,9 +6969,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.9", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9668,9 +9668,9 @@
} }
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"devOptional": true, "devOptional": true,
"funding": [ "funding": [
{ {
@@ -10702,9 +10702,9 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.7.0", "version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14291,9 +14291,9 @@
} }
}, },
"node_modules/release-it/node_modules/undici": { "node_modules/release-it/node_modules/undici": {
"version": "7.29.0", "version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -14673,9 +14673,9 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.1.1", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
+6
View File
@@ -114,6 +114,12 @@
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0" "vitest": "5.0.0"
}, },
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
},
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
"fsevents@2.3.3": 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); 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 () => { test("compact JSX and contract nulls need no local suppressions", async () => {
const eslint = new ESLint({ fix: true }); const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
import type { ExpenseCategory } from "../api/types"; import type { ExpenseCategory } from "../api/types";
const MAX_CATEGORY_NAME_LENGTH = 80; const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
function categorySchema(categories?: ExpenseCategory[], editingId?: string) { function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
return z.object({ return z.object({
name: z 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 { FormProvider, useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { CategoryValues } from "../../model/category.schema";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import { useCategoryManagerVm } from "./CategoryManager.vm"; import {
type CategoryManagerProps,
type CategoryManagerProps = { useCategoryManagerVm,
onClose: () => void; } from "./CategoryManager.vm";
onCreated?: (id: string) => void;
};
type CategoryValues = { name: string };
const CategoryFields: FC = () => { const CategoryFields: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,7 +12,7 @@ import {
useUpdateExpenseCategory, useUpdateExpenseCategory,
} from "../../api/mutations"; } from "../../api/mutations";
import { useBankCategories } from "../../api/queries"; import { useBankCategories } from "../../api/queries";
import { categorySchema } from "../../model/category.schema"; import { categorySchema, type CategoryValues } from "../../model/category.schema";
import { useBankFormFocus } from "../useBankFormFocus"; import { useBankFormFocus } from "../useBankFormFocus";
type CategoryManagerProps = { type CategoryManagerProps = {
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
}; };
type CategoryValues = { name: string };
function useCategoryManagerVm(props: CategoryManagerProps) { function useCategoryManagerVm(props: CategoryManagerProps) {
useBankFormFocus(); useBankFormFocus();
const [opened, setOpened] = useState(true); const [opened, setOpened] = useState(true);
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
const isPending = !opened || methods.formState.isSubmitting; const isPending = !opened || methods.formState.isSubmitting;
const onRequestClose = () => !isPending && setOpened(false); const onRequestClose = () => !isPending && setOpened(false);
const finishEditing = () => {
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
};
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => { const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
void methods.handleSubmit(async (values) => { void methods.handleSubmit(async (values) => {
setError(undefined); setError(undefined);
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
if (result) props.onCreated?.(result.id); if (result) props.onCreated?.(result.id);
} }
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} }
catch (error_) { catch (error_) {
setError(error_); setError(error_);
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
}; };
const onCancel = () => { const onCancel = () => {
editSessionRef.current++; finishEditing();
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined); 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 { useTranslation } from "react-i18next";
import { Link } from "react-router"; 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"; 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 BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props; const { accounts } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
<BrokerageAccountSummary summary={summary} /> <BrokerageAccountSummary summary={summary} />
{!!source?.lastSuccessfulAt && ( <BrokerageAccountSourceStatus source={source} />
<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>}
</Stack> </Stack>
{!!positionCounts && ( {!!positionCounts && (
@@ -5,7 +5,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
@@ -33,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props; const { events } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{events.state === "loaded" && ( {events.state === "loaded" && (
<> <SourceSyncStatus
{!!events.lastSuccessfulAt && ( errorText={errorText}
<Text c="dimmed" size="xs"> lastSuccessfulAt={events.lastSuccessfulAt}
{t("investment.sourceTime", { stale={events.stale}
date: formatSnapshotTime(events.lastSuccessfulAt), />
})}
</Text>
)}
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
)} )}
{events.state === "loading" && ( {events.state === "loading" && (
+1
View File
@@ -1,3 +1,4 @@
export { toInstrumentEventRows } from "./model/operation-rows";
export { section, type SectionState } from "./model/section"; export { section, type SectionState } from "./model/section";
export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { useInstrumentParameters } from "./model/useInstrumentParameters";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; 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 { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
{props.controls} {props.controls}
</Group> </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.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 && ( {!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs"> <Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")} {t("accounts.refreshing")}
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
type InvestmentRefreshStateProps = { type InvestmentRefreshStateProps = {
labelScope?: "accounts" | "portfolio"; labelScope?: "accounts" | "portfolio";
@@ -20,13 +20,7 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
return ( return (
<> <>
{!!props.lastSuccessfulAt && ( <SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<Button <Button
disabled={refreshing} disabled={refreshing}
@@ -16,7 +16,7 @@ import {
BrokerageEventCard, BrokerageEventCard,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,13 +55,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!!props.lastSuccessfulAt && ( <SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
@@ -13,7 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
@@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
{!!props.lastSuccessfulAt && ( <SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<Title order={1}>{t("income.title")}</Title> <Title order={1}>{t("income.title")}</Title>
@@ -7,7 +7,6 @@ import {
MultiSelect, MultiSelect,
Progress, Progress,
Stack, Stack,
Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
@@ -15,7 +14,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -63,13 +62,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!!props.lastSuccessfulAt && ( <SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.lastSuccessfulAt),
})}
</Text>
)}
<Title order={1}>{t("ledger.title")}</Title> <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 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 type IncomeSummaryProps
= | { = | {
@@ -25,6 +26,12 @@ type IncomeSummaryProps
const IncomeSummary: FC<IncomeSummaryProps> = (props) => { const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial") if (props.state === "loading" || props.state === "partial")
return ( return (
<Text c="dimmed" role="status"> <Text c="dimmed" role="status">
@@ -59,17 +66,11 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return ( return (
<Stack gap="xs"> <Stack gap="xs">
{!!props.lastSuccessfulAt && ( <SourceSyncStatus
<Text c="dimmed" size="xs"> errorText={errorText}
{t("investment.sourceTime", { lastSuccessfulAt={props.lastSuccessfulAt}
date: formatSnapshotTime(props.lastSuccessfulAt), stale={props.stale}
})} />
</Text>
)}
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.refreshError && <Text role="status">{t("income.refreshError")}</Text>}
{props.summary.totalsByCurrency.map(total => ( {props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder> <Paper key={total.currency} p="sm" withBorder>
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation"; import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatSnapshotTime } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews"; import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,22 +18,19 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { operations } = props; const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{operations.state === "loaded" && ( {operations.state === "loaded" && (
<> <SourceSyncStatus
{!!operations.lastSuccessfulAt && ( errorText={errorText}
<Text c="dimmed" size="xs"> lastSuccessfulAt={operations.lastSuccessfulAt}
{t("investment.sourceTime", { stale={operations.stale}
date: formatSnapshotTime(operations.lastSuccessfulAt), />
})}
</Text>
)}
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!operations.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
</>
)} )}
{operations.state === "loading" && ( {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 () => { it("keeps unknown membership loading instead of declaring no accounts", async () => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
@@ -15,31 +15,31 @@ function useAccountsPageViewModel() {
) { ) {
if (!accounts.isError && accounts.meta?.sync.state !== "failed") if (!accounts.isError && accounts.meta?.sync.state !== "failed")
return { state: "loading" as const }; return { state: "loading" as const };
if (accounts.isError || accounts.meta?.sync.state === "failed")
return { return {
code: apiErrorCode(accounts.error), code: apiErrorCode(accounts.error),
retry: sync.retry, retry: sync.retry,
state: "error" as const, state: "error" as const,
}; };
} }
if (!accounts.data?.length) const refresh = {
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),
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
refreshError: accounts.isError || sync.transportError || sync.sourceError, refreshError: accounts.isError || sync.transportError || sync.sourceError,
refreshing: accounts.isFetching || sync.refreshing, refreshing: accounts.isFetching || sync.refreshing,
retry: sync.retry, retry: sync.retry,
sourceError: sync.sourceError, 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, state: "loaded" as const,
}; };
} }
+9 -22
View File
@@ -9,10 +9,14 @@ import {
useBondQuote, useBondQuote,
} from "@/entities/bond"; } from "@/entities/bond";
import { useBrokerageOperationHistory } from "@/entities/broker-operation"; 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 { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
type BondPageVm = ReturnType<typeof useBondPageVm>; type BondPageVm = ReturnType<typeof useBondPageVm>;
@@ -62,20 +66,7 @@ function useBondPageVm() {
code: apiErrorCode(bond.error), code: apiErrorCode(bond.error),
coupons: coupons.data, coupons: coupons.data,
couponsState: section(coupons, sync.retry), couponsState: section(coupons, sync.retry),
eventRows: eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? bond.data?.logoName
?? null,
ticker:
item.ticker
?? bond.data?.ticker
?? null,
})) ?? [],
filter, filter,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(bond.data?.logoName), logoSrc: instrumentLogoUrl(bond.data?.logoName),
@@ -92,9 +83,7 @@ function useBondPageVm() {
coupons.data?.coupons coupons.data?.coupons
.filter(event => event.timing !== "upcoming") .filter(event => event.timing !== "upcoming")
.toSorted((a, b) => .toSorted((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare( compareDateDesc(a.paymentDate, b.paymentDate),
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [], ) ?? [],
period, period,
position: position.data, position: position.data,
@@ -110,9 +99,7 @@ function useBondPageVm() {
coupons.data?.coupons coupons.data?.coupons
.filter(event => event.timing === "upcoming") .filter(event => event.timing === "upcoming")
.toSorted((a, b) => .toSorted((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare( compareDateDesc(b.paymentDate, a.paymentDate),
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [], ) ?? [],
}; };
} }
@@ -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"; } from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod. // ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode { enum FormIssueCode {
Custom = "custom", Custom = "custom",
@@ -43,6 +41,8 @@ const operationSchema = z.object({
targetCardId: z.string(), targetCardId: z.string(),
}); });
type OperationValues = z.infer<typeof operationSchema>;
function createOperationSchema({ function createOperationSchema({
cards, cards,
categories, categories,
@@ -83,16 +83,19 @@ function createOperationSchema({
path: ["date"], path: ["date"],
...validationMessage("bank.validation.start")(undefined), ...validationMessage("bank.validation.start")(undefined),
}); });
if (values.kind === "refund")
if (values.kind === "refund") {
validateRefund(values, context, expense, limit); validateRefund(values, context, expense, limit);
if (
values.kind === "expense" return;
&& !categories?.some( }
c =>
c.id if (values.kind !== "expense") return;
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
) const effectiveCategoryId
) = values.categoryId || categories?.find(c => c.isDefault)?.id;
if (!categories?.some(c => c.id === effectiveCategoryId))
context.addIssue({ context.addIssue({
code: FormIssueCode.Custom, code: FormIssueCode.Custom,
path: ["categoryId"], path: ["categoryId"],
@@ -128,4 +131,4 @@ function validateRefund(
}); });
} }
export { createOperationSchema }; export { createOperationSchema, type OperationValues };
@@ -4,6 +4,8 @@ import type {
BankOperation, BankOperation,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.schema";
type Choice = { label: string; value: string }; type Choice = { label: string; value: string };
type OperationFieldsProps = { type OperationFieldsProps = {
@@ -26,20 +28,11 @@ type OperationFormProps = {
operation?: BankOperation; operation?: BankOperation;
}; };
type OperationValues = {
amount: string;
cardId: string;
categoryId: string;
date: string;
description: string;
kind: BankOperation["kind"];
originalExpenseId: string;
targetCardId: string;
};
export type { export type {
Choice, Choice,
OperationFieldsProps, OperationFieldsProps,
OperationFormProps, OperationFormProps,
OperationValues,
}; };
export { type OperationValues } from "./OperationForm.schema";
@@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [submittedValues, setSubmittedValues] = useState<OperationValues>(); const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const categories = useBankCategories(); const categories = useBankCategories();
const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
const operationCreation = useCreateBankOperation(); const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation(); const updateOperation = useUpdateBankOperation();
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
}), }),
...(values.kind === "expense" && { ...(values.kind === "expense" && {
categoryId: categoryId:
values.categoryId || categories.data?.find(c => c.isDefault)?.id, values.categoryId || defaultCategoryId,
}), }),
...(values.kind === "refund" && { ...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId, originalExpenseId: values.originalExpenseId,
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
cards: options, cards: options,
categories: categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [], categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "", defaultCategory: defaultCategoryId ?? "",
editing: Boolean(props.operation), editing: Boolean(props.operation),
kind, kind,
kinds: bankOperationKinds.map(value => ({ kinds: bankOperationKinds.map(value => ({
+5 -21
View File
@@ -11,11 +11,12 @@ import {
} from "@/entities/stock"; } from "@/entities/stock";
import { import {
section, section,
toInstrumentEventRows,
useInstrumentParameters, useInstrumentParameters,
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
type StockPageVm = ReturnType<typeof useStockPageVm>; type StockPageVm = ReturnType<typeof useStockPageVm>;
@@ -65,20 +66,7 @@ function useStockPageVm() {
companyState: section(company, sync.retry), companyState: section(company, sync.retry),
dividends: dividends.data, dividends: dividends.data,
dividendsState: section(dividends, sync.retry), dividendsState: section(dividends, sync.retry),
eventRows: eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
operations.data?.items.map(item => ({
...item,
logoName:
item.logoName
?? stock.data?.logoName
?? null,
ticker:
item.ticker
?? stock.data?.ticker
?? null,
})) ?? [],
filter, filter,
hasPrevious: history.hasPrevious, hasPrevious: history.hasPrevious,
logoSrc: instrumentLogoUrl(stock.data?.logoName), logoSrc: instrumentLogoUrl(stock.data?.logoName),
@@ -93,9 +81,7 @@ function useStockPageVm() {
dividends.data?.dividends dividends.data?.dividends
.filter(event => event.timing !== "upcoming") .filter(event => event.timing !== "upcoming")
.toSorted((a, b) => .toSorted((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare( compareDateDesc(a.paymentDate, b.paymentDate),
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [], ) ?? [],
period, period,
position: position.data, position: position.data,
@@ -113,9 +99,7 @@ function useStockPageVm() {
dividends.data?.dividends dividends.data?.dividends
.filter(event => event.timing === "upcoming") .filter(event => event.timing === "upcoming")
.toSorted((a, b) => .toSorted((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare( compareDateDesc(b.paymentDate, a.paymentDate),
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [], ) ?? [],
}; };
} }
+19 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isValidPeriod, moscowDate } from "./calendar"; import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
import { calendarDate } from "./calendar.schema"; import { calendarDate } from "./calendar.schema";
describe("calendar dates", () => { 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-31T20:59:59Z"))).toBe("2026-12-31");
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01"); 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", 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 { function moscowDate(value: Date): string {
const parts = moscowFormatter.formatToParts(value); const parts = moscowFormatter.formatToParts(value);
@@ -25,4 +31,4 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`; 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 { calendarDate } from "./calendar.schema";
export { export {
formatChartValue, 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";