From 53c852b437ee1174843a4f959722239132e9150a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 13 Sep 2026 14:37:25 +0300 Subject: [PATCH] refactor: enforce semantic values and numeric lint rules --- .ai/CONVENTIONS.md | 2 +- .ai/conventions/frontend-ui.md | 51 +++++++++++++++++++ biome.json | 18 ++++++- src/app/routes/RouteTransition.tsx | 7 ++- src/entities/bank-card/api/queries.ts | 6 ++- .../bank-card/model/form-validation.ts | 22 ++++++-- src/entities/bank-card/ui/CategoryManager.tsx | 5 +- src/entities/bond/api/queries.ts | 30 +++++++---- src/entities/portfolio/model/display.ts | 1 + src/entities/stock/api/queries.ts | 25 +++++---- src/features/auth/model/validation.ts | 16 ++++-- .../ui/InstrumentSection.view.tsx | 11 +++- .../AccountEventsPageLoading.view.tsx | 6 ++- .../account-income/ui/AccountIncomePage.vm.ts | 4 +- .../AccountOperationsPageLoading.view.tsx | 6 ++- src/pages/account/ui/AccountPage.vm.ts | 4 +- .../ui/components/PortfolioStructure.tsx | 4 +- src/pages/bonds/ui/BondsPage.vm.ts | 4 +- src/pages/card/ui/OperationForm.tsx | 7 ++- src/pages/stocks/ui/StocksPage.vm.ts | 4 +- src/shared/api/client.ts | 4 +- 21 files changed, 190 insertions(+), 47 deletions(-) diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md index 6e5582c..92013d5 100644 --- a/.ai/CONVENTIONS.md +++ b/.ai/CONVENTIONS.md @@ -9,7 +9,7 @@ | Создать/перенести module/component/slice; выбрать page/entity/feature/shared; cross-slice imports | [Architecture](conventions/frontend-architecture.md) | | Component/props, container/VM/view, form, local state/effect, loading/error/retry, Mantine/UI | [UI](conventions/frontend-ui.md) | | Только продуктовая строка или Zod message | [Строки](conventions/frontend-ui.md#строки-и-валидация) | -| Только TS declaration/import/export | [Код](conventions/frontend-ui.md#код-и-components) | +| TS declaration/import/export, domain literals, magic values | [Код](conventions/frontend-ui.md#код-и-components) + [Domain literals](conventions/frontend-ui.md#domain-literals-and-magic-values) | | query/mutation, shared/api, server state/cache, generated types, Bearer/refresh | [Data](conventions/frontend-data.md) | | Тесты или behavior с testing risk | [Testing](conventions/frontend-testing.md) | | Новый abstraction/dependency, refactor или неоднозначный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) | diff --git a/.ai/conventions/frontend-ui.md b/.ai/conventions/frontend-ui.md index a4d9c8d..8994521 100644 --- a/.ai/conventions/frontend-ui.md +++ b/.ai/conventions/frontend-ui.md @@ -91,6 +91,57 @@ CSF `*.stories.tsx` сохраняют default meta и прямые story export Vite/Vitest configs и `.storybook/main.ts`/`preview.tsx` имеют только конфигурационное default-export exception. Scope исключений принадлежит biome.json. +## Domain literals and magic values + +Finite business vocabulary must be modeled explicitly; incidental implementation +literals stay inline. Это относится к error codes, statuses, operation/account/ +instrument/transaction types, permissions, roles, feature states и бизнес-режимам. + +Перед новым определением найди existing owner/type/value. В branching и comparisons +используй символическое значение владельца; не повторяй raw domain literals. +Храни словарь у domain/module/entity с соблюдением границ слоёв, без глобальной +свалки `constants.ts`. Сохраняй принятый pattern; для нового project-owned словаря +без подходящего аналога используй `as const` с derived union: + +```ts +const BankErrorCode = { + InvalidDate: "BANK_INVALID_DATE", + ImmutableOperation: "BANK_IMMUTABLE_OPERATION", +} as const; +type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; + +// Вместо code === "BANK_INVALID_DATE": +if (code === BankErrorCode.InvalidDate) { /* existing handling */ } +``` + +Значимые numeric policies (timeout, retry, page size, threshold, coefficient) +называй по смыслу и единицам: `REQUEST_TIMEOUT_MS`, `CACHE_TTL_SECONDS`, +`MAX_RETRY_COUNT`, `PAGE_SIZE`. Оставляй inline UI labels в принятом месте, +log messages, индексы, `index + 1`, `Math.max(value, 0)`, `page ?? 1`, +`percentage * 100` и очевидные локальные детали без semantic benefit. +При неясном owner/смысле оставь значение и укажи ограничение в review. + +Не мигрируй корректные existing enum/union styles массово. Не добавляй ради +правила factories, registries, schemas, loaders, abstraction layers, dependencies, +custom lint/AST rules, generation/validation scripts или тесты существования +констант. Semantic review проверяет ownership, единое написание и неизменность +wire values; обычные behavior tests проверяют поведение. + +Backend API vocabulary бери из generated OpenAPI types/client, runtime values — +когда генератор их предоставляет. Не создавай ручную копию backend enum/object. +Текущий `schema.d.ts` предоставляет только types: используй indexed-access type +из `paths`; typed literals в запросах/сравнениях допустимы при отсутствии +runtime values. `ProblemDetailsDto.code` сейчас `string`: не выдумывай закрытый +frontend error enum. Развитие API source — отдельное contract-решение. +Frontend `enum` по-прежнему запрещён локальным TS/style profile. + +`noMagicNumbers` включён как error в Biome. Fixtures, tests и stories исключены +из этого правила; остальные проверки для них сохраняются. Используй имена +политик/единиц и existing values. Точечное подавление допустимо для очевидного +incidental значения с объяснением, например перевода доли в проценты; не подавляй +скрытые бизнес-лимиты. Semantic magic strings проверяются conventions/review: в +установленном Biome нет такого встроенного правила, новый инструмент не добавляем. + ## Строки и валидация Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через diff --git a/biome.json b/biome.json index 8069a8b..6eff3f8 100644 --- a/biome.json +++ b/biome.json @@ -27,7 +27,8 @@ }, "style": { "noDefaultExport": "error", - "useExportsLast": "error" + "useExportsLast": "error", + "noMagicNumbers": "error" } } }, @@ -57,6 +58,21 @@ } } } + }, + { + "includes": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.tsx", + "src/test/**" + ], + "linter": { + "rules": { + "style": { + "noMagicNumbers": "off" + } + } + } } ] } diff --git a/src/app/routes/RouteTransition.tsx b/src/app/routes/RouteTransition.tsx index 6b2347d..3fdaac8 100644 --- a/src/app/routes/RouteTransition.tsx +++ b/src/app/routes/RouteTransition.tsx @@ -3,6 +3,8 @@ import * as m from "motion/react-m"; import type { FC, PropsWithChildren } from "react"; import { useLocation } from "react-router"; +const TRANSITION_DURATION_SECONDS = 0.2; + const RouteTransition: FC = (props) => { const { children } = props; const { pathname } = useLocation(); @@ -14,7 +16,10 @@ const RouteTransition: FC = (props) => { key={pathname} initial={reduceMotion ? false : { opacity: 0 }} animate={{ opacity: 1 }} - transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }} + transition={{ + duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS, + ease: "easeOut", + }} > {children} diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index 8773936..3f1b5c5 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -30,6 +30,8 @@ type CreateExpenseCategory = type UpdateExpenseCategory = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +const OPERATIONS_PAGE_SIZE = 100; + function useBankCards(includeArchived = false) { return useAuthenticatedQuery( apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { @@ -71,7 +73,7 @@ function useBankOperations( query: { ...query, page: all ? 1 : query.page, - limit: query.limit === "all" ? 100 : query.limit, + limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit, }, }; const options = apiQueryClient.queryOptions( @@ -87,7 +89,7 @@ function useBankOperations( ) => { const previous = previousQuery?.queryKey[2]?.params; return !all && - previousQuery?.queryKey.length === 3 && + previousQuery?.queryKey.length === options.queryKey.length && previous?.path?.id === id && previous?.query?.month === query.month && previous?.query?.from === query.from && diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index e1b60da..e33ef3e 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,6 +1,9 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +const ISO_MONTH_LENGTH = 7; +const MAX_CARD_NAME_LENGTH = 100; + function bankToday() { return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Moscow", @@ -15,7 +18,7 @@ function bankDateInputValue(value: string) { function bankMonth(value: string | null) { return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value - : bankToday().slice(0, 7); + : bankToday().slice(0, ISO_MONTH_LENGTH); } function bankMoneyValue(value: string) { return value @@ -32,12 +35,15 @@ function moneyCents(value: string): bigint | null { (normalized.startsWith("-") ? -1n : 1n) ); } +const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; +const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; + const moneySchema = z.string().refine((value) => { const cents = moneyCents(value); return ( cents !== null && - cents >= -9223372036854775808n && - cents <= 9223372036854775807n + cents >= MIN_BALANCE_KOPECKS && + cents <= MAX_BALANCE_KOPECKS ); }, validationMessage("bank.validation.money")); const positiveMoneySchema = moneySchema.refine((value) => { @@ -60,11 +66,17 @@ const bankCardSchema = z.object({ .string() .trim() .min(1, validationMessage("bank.validation.name")(undefined)) - .max(100, validationMessage("bank.validation.name")(undefined)), + .max( + MAX_CARD_NAME_LENGTH, + validationMessage("bank.validation.name")(undefined), + ), bankName: z .string() .trim() - .max(100, validationMessage("bank.validation.bankName")(undefined)), + .max( + MAX_CARD_NAME_LENGTH, + validationMessage("bank.validation.bankName")(undefined), + ), lastFour: z .string() .refine( diff --git a/src/entities/bank-card/ui/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager.tsx index f4d4813..5f629f6 100644 --- a/src/entities/bank-card/ui/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager.tsx @@ -25,6 +25,8 @@ import classes from "./BankForms.module.css"; import { BankOperationIcon } from "./BankOperationIcon"; import { useBankFormFocus } from "./useBankFormFocus"; +const MAX_CATEGORY_NAME_LENGTH = 80; + type CategoryManagerProps = { onClose: () => void; onCreated?: (id: string) => void; @@ -67,7 +69,8 @@ const CategoryManager: FC = (props) => { .string() .trim() .refine( - (value) => value.length > 0 && value.length <= 80, + (value) => + value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, validationMessage("bank.validation.category"), ) .refine( diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 9c153d1..4c21dc5 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -1,6 +1,16 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; -type BondPeriod = "month" | "halfYear" | "year" | "all"; +type BondPeriod = NonNullable< + NonNullable< + paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] + >["period"] +>; + +const BOND_STALE_TIME_MS = 30_000; function useBondSearch(query: string) { return useAuthenticatedQuery({ @@ -8,7 +18,7 @@ function useBondSearch(query: string) { params: { query: { query } }, }), enabled: query.length >= 2, - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } function bondQueryOptions(uid: string) { @@ -16,7 +26,7 @@ function bondQueryOptions(uid: string) { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { params: { path: { uid } }, }), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }; } @@ -35,7 +45,7 @@ function useBond(identifier: string) { params: { path: { ticker: identifier } }, }), enabled: Boolean(identifier) && !isUid, - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); return isUid ? byUid : byTicker; } @@ -45,7 +55,7 @@ function useBondMetrics(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } function useBondQuote(uid: string) { @@ -54,7 +64,7 @@ function useBondQuote(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } function useBondCandles(uid: string, period: BondPeriod) { @@ -63,7 +73,7 @@ function useBondCandles(uid: string, period: BondPeriod) { params: { path: { uid }, query: { period } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } function useBondPosition(uid: string) { @@ -72,7 +82,7 @@ function useBondPosition(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } function useBondCoupons(uid: string) { @@ -81,7 +91,7 @@ function useBondCoupons(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: BOND_STALE_TIME_MS, }); } diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index e76240a..f4938e8 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -9,6 +9,7 @@ function formatShare( if (!numerator || !denominator) return null; const total = Number(denominator.value); if (!Number.isFinite(total) || total <= 0) return null; + // biome-ignore lint/style/noMagicNumbers: obvious conversion of a ratio to percent. const percent = (Number(numerator.value) / total) * 100; if (!Number.isFinite(percent)) return null; const rounded = new Intl.NumberFormat("ru-RU", { diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 47574b4..29967c0 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -1,10 +1,17 @@ import { apiQueryClient, + type paths, useAuthenticatedQueries, useAuthenticatedQuery, } from "@/shared/api"; -type StockPeriod = "month" | "halfYear" | "year" | "all"; +type StockPeriod = NonNullable< + NonNullable< + paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] + >["period"] +>; + +const STOCK_STALE_TIME_MS = 30_000; function useStockSearch(query: string) { return useAuthenticatedQuery({ @@ -12,7 +19,7 @@ function useStockSearch(query: string) { params: { query: { query } }, }), enabled: query.length >= 2, - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } function stockQueryOptions(uid: string) { @@ -20,7 +27,7 @@ function stockQueryOptions(uid: string) { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { params: { path: { uid } }, }), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }; } @@ -47,7 +54,7 @@ function useStock(identifier: string) { params: { path: { ticker: identifier } }, }), enabled: Boolean(identifier) && !isUid, - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); return isUid ? byUid : byTicker; } @@ -57,7 +64,7 @@ function useStockQuote(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } function useStockCandles(uid: string, period: StockPeriod) { @@ -66,7 +73,7 @@ function useStockCandles(uid: string, period: StockPeriod) { params: { path: { uid }, query: { period } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } function useStockPosition(uid: string) { @@ -75,7 +82,7 @@ function useStockPosition(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } function useStockCompany(uid: string) { @@ -84,7 +91,7 @@ function useStockCompany(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } function useStockDividends(uid: string) { @@ -93,7 +100,7 @@ function useStockDividends(uid: string) { params: { path: { uid } }, }), enabled: Boolean(uid), - staleTime: 30_000, + staleTime: STOCK_STALE_TIME_MS, }); } diff --git a/src/features/auth/model/validation.ts b/src/features/auth/model/validation.ts index 5725da0..8ddfc66 100644 --- a/src/features/auth/model/validation.ts +++ b/src/features/auth/model/validation.ts @@ -1,11 +1,17 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; +const ASCII_SPACE = 32; +const ASCII_DELETE = 127; +const MAX_EMAIL_LENGTH = 254; +const MIN_PASSWORD_BYTES = 12; +const MAX_PASSWORD_BYTES = 72; + function isEmailCharacterInvalid(character: string): boolean { return ( /\s/.test(character) || - character.charCodeAt(0) < 32 || - character.charCodeAt(0) === 127 + character.charCodeAt(0) < ASCII_SPACE || + character.charCodeAt(0) === ASCII_DELETE ); } @@ -13,7 +19,7 @@ const email = z .string() .trim() .toLowerCase() - .max(254) + .max(MAX_EMAIL_LENGTH) .refine( (value) => value.split("@").length === 2 && @@ -25,8 +31,8 @@ const password = z .string() .refine( (value) => - new TextEncoder().encode(value).length >= 12 && - new TextEncoder().encode(value).length <= 72, + new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES && + new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, validationMessage("auth.validation.invalidPasswordLength"), ); diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 41a1c1d..f106845 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -14,6 +14,9 @@ import { LoadingSkeleton } from "@/shared/ui"; import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; +const CHART_SKELETON_HEIGHT_PX = 280; +const SECTION_SKELETON_HEIGHT_PX = 65; + type InstrumentSectionProps = PropsWithChildren<{ title: string; state: SectionState; @@ -59,7 +62,13 @@ const InstrumentSection: FC = (props) => { label={t("stock.loadingSection", { section: props.title })} > - + diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx index 6745ff2..0370db8 100644 --- a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx +++ b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx @@ -3,6 +3,8 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { LoadingSkeleton } from "@/shared/ui"; +const skeletonRowIds = ["first", "second", "third", "fourth"]; + const AccountEventsPageLoading: FC = () => { const { t } = useTranslation(); return ( @@ -17,8 +19,8 @@ const AccountEventsPageLoading: FC = () => { - {[0, 1, 2, 3].map((index) => ( - + {skeletonRowIds.map((id) => ( + ))} ); diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index 07f4a66..431d265 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -7,6 +7,8 @@ import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; +const ISO_MONTH_LENGTH = 7; + type IncomePeriod = { from: string; to: string }; type IncomeForm = IncomePeriod; @@ -143,7 +145,7 @@ function draftPeriod(period: IncomePeriod): IncomeForm { function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); - return { from: `${today.slice(0, 7)}-01`, to: today }; + return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today }; } function validPeriod(period: IncomePeriod): period is IncomePeriod { diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx index e9e2d7b..df54e8e 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx @@ -3,6 +3,8 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { LoadingSkeleton } from "@/shared/ui"; +const skeletonRowIds = ["first", "second", "third", "fourth"]; + const AccountOperationsPageLoading: FC = () => { const { t } = useTranslation(); @@ -19,8 +21,8 @@ const AccountOperationsPageLoading: FC = () => { - {[0, 1, 2, 3].map((index) => ( - + {skeletonRowIds.map((id) => ( + ))} diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 4064199..eb221f8 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -17,6 +17,8 @@ import { useStocks } from "@/entities/stock"; import { apiErrorCode } from "@/shared/api"; import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; +const RECENT_EVENTS_LIMIT = 5; + type PositionSectionKind = "stocks" | "bonds" | "other"; type AccountPageReturnTone = "positive" | "negative"; @@ -176,7 +178,7 @@ function toRecentEvents( const to = `/accounts/${accountId}/events`; if (events.isLoading) return { state: "loading", to }; if (events.isError) return { state: "error", to }; - const items = events.data?.events.slice(0, 5) ?? []; + const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; if (!items.length) return { state: "empty", to }; return { state: "loaded", to, items }; } diff --git a/src/pages/account/ui/components/PortfolioStructure.tsx b/src/pages/account/ui/components/PortfolioStructure.tsx index a1fc00a..cd28b97 100644 --- a/src/pages/account/ui/components/PortfolioStructure.tsx +++ b/src/pages/account/ui/components/PortfolioStructure.tsx @@ -4,6 +4,8 @@ import type { Portfolio } from "@/entities/portfolio"; import { formatMoney } from "@/shared/lib"; import { PortfolioStructureView } from "./PortfolioStructure.view"; +const MIN_VISIBLE_SHARE_PERCENT = 0.01; + // A cached snapshot or an older running server can predate this response field. type PortfolioStructureProps = { structure?: Portfolio["structure"] | null }; @@ -45,7 +47,7 @@ const PortfolioStructure: FC = (props) => { share: percentage === null ? t("portfolio.structure.shareUnavailable") - : percentage > 0 && percentage < 0.01 + : percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT ? t("portfolio.structure.smallShare") : `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`, value: percentage ?? 0, diff --git a/src/pages/bonds/ui/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage.vm.ts index dc6fac7..55f5b2f 100644 --- a/src/pages/bonds/ui/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage.vm.ts @@ -4,10 +4,12 @@ import { useBondSearch } from "@/entities/bond"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; +const SEARCH_DEBOUNCE_MS = 300; + function useBondsPageVm() { const [params, setParams] = useSearchParams(); const query = params.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), 300); + const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const result = useBondSearch(debounced); const waiting = query.trim() !== debounced; return { diff --git a/src/pages/card/ui/OperationForm.tsx b/src/pages/card/ui/OperationForm.tsx index 869e1c0..778d80d 100644 --- a/src/pages/card/ui/OperationForm.tsx +++ b/src/pages/card/ui/OperationForm.tsx @@ -26,6 +26,8 @@ import classes from "./OperationForm.module.css"; import type { OperationValues } from "./OperationForm.types"; import { OperationFields, RefundFields } from "./OperationForm.view"; +const MAX_DESCRIPTION_LENGTH = 500; + const operationSchema = z.object({ kind: z.enum(["expense", "topup", "transfer", "refund"]), cardId: z.string(), @@ -36,7 +38,10 @@ const operationSchema = z.object({ date: bankDateSchema, description: z .string() - .max(500, validationMessage("bank.validation.description")(undefined)), + .max( + MAX_DESCRIPTION_LENGTH, + validationMessage("bank.validation.description")(undefined), + ), }); type OperationFormProps = { diff --git a/src/pages/stocks/ui/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage.vm.ts index b1c3eeb..0982818 100644 --- a/src/pages/stocks/ui/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage.vm.ts @@ -4,10 +4,12 @@ import { useStockSearch } from "@/entities/stock"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; +const SEARCH_DEBOUNCE_MS = 300; + function useStocksPageVm() { const [params, setParams] = useSearchParams(); const query = params.get("q") ?? ""; - const [debounced] = useDebouncedValue(query.trim(), 300); + const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const result = useStockSearch(debounced); const waiting = query.trim() !== debounced; return { diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index ab2fefb..cb87d40 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -3,6 +3,8 @@ import createQueryClient from "openapi-react-query"; import type { paths } from "./generated/schema"; +const HTTP_UNAUTHORIZED = 401; + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000"; type AuthHandlers = { @@ -50,7 +52,7 @@ async function authenticatedFetch(request: Request): Promise { const retryRequest = authorized.clone(); const response = await fetch(authorized); if ( - response.status !== 401 || + response.status !== HTTP_UNAUTHORIZED || isAuthRequest || !auth || (