refactor: enforce semantic values and numeric lint rules

This commit is contained in:
2026-09-13 14:37:25 +03:00
parent 24d332e063
commit 53c852b437
21 changed files with 190 additions and 47 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
| Создать/перенести module/component/slice; выбрать page/entity/feature/shared; cross-slice imports | [Architecture](conventions/frontend-architecture.md) | | Создать/перенести 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) | | 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#строки-и-валидация) | | Только продуктовая строка или 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) | | query/mutation, shared/api, server state/cache, generated types, Bearer/refresh | [Data](conventions/frontend-data.md) |
| Тесты или behavior с testing risk | [Testing](conventions/frontend-testing.md) | | Тесты или behavior с testing risk | [Testing](conventions/frontend-testing.md) |
| Новый abstraction/dependency, refactor или неоднозначный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) | | Новый abstraction/dependency, refactor или неоднозначный scope | [Общий выбор реализации](../../docs/development/engineering-governance.md#выбор-реализации) |
+51
View File
@@ -91,6 +91,57 @@ CSF `*.stories.tsx` сохраняют default meta и прямые story export
Vite/Vitest configs и `.storybook/main.ts`/`preview.tsx` имеют только Vite/Vitest configs и `.storybook/main.ts`/`preview.tsx` имеют только
конфигурационное default-export exception. Scope исключений принадлежит biome.json. конфигурационное 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`, читается через Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
+17 -1
View File
@@ -27,7 +27,8 @@
}, },
"style": { "style": {
"noDefaultExport": "error", "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"
}
}
}
} }
] ]
} }
+6 -1
View File
@@ -3,6 +3,8 @@ import * as m from "motion/react-m";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
const TRANSITION_DURATION_SECONDS = 0.2;
const RouteTransition: FC<PropsWithChildren> = (props) => { const RouteTransition: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -14,7 +16,10 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
key={pathname} key={pathname}
initial={reduceMotion ? false : { opacity: 0 }} initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }} transition={{
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut",
}}
> >
{children} {children}
</m.div> </m.div>
+4 -2
View File
@@ -30,6 +30,8 @@ type CreateExpenseCategory =
type UpdateExpenseCategory = type UpdateExpenseCategory =
paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100;
function useBankCards(includeArchived = false) { function useBankCards(includeArchived = false) {
return useAuthenticatedQuery( return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
@@ -71,7 +73,7 @@ function useBankOperations(
query: { query: {
...query, ...query,
page: all ? 1 : query.page, 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( const options = apiQueryClient.queryOptions(
@@ -87,7 +89,7 @@ function useBankOperations(
) => { ) => {
const previous = previousQuery?.queryKey[2]?.params; const previous = previousQuery?.queryKey[2]?.params;
return !all && return !all &&
previousQuery?.queryKey.length === 3 && previousQuery?.queryKey.length === options.queryKey.length &&
previous?.path?.id === id && previous?.path?.id === id &&
previous?.query?.month === query.month && previous?.query?.month === query.month &&
previous?.query?.from === query.from && previous?.query?.from === query.from &&
@@ -1,6 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
const ISO_MONTH_LENGTH = 7;
const MAX_CARD_NAME_LENGTH = 100;
function bankToday() { function bankToday() {
return new Intl.DateTimeFormat("en-CA", { return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow", timeZone: "Europe/Moscow",
@@ -15,7 +18,7 @@ function bankDateInputValue(value: string) {
function bankMonth(value: string | null) { function bankMonth(value: string | null) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value ? value
: bankToday().slice(0, 7); : bankToday().slice(0, ISO_MONTH_LENGTH);
} }
function bankMoneyValue(value: string) { function bankMoneyValue(value: string) {
return value return value
@@ -32,12 +35,15 @@ function moneyCents(value: string): bigint | null {
(normalized.startsWith("-") ? -1n : 1n) (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 moneySchema = z.string().refine((value) => {
const cents = moneyCents(value); const cents = moneyCents(value);
return ( return (
cents !== null && cents !== null &&
cents >= -9223372036854775808n && cents >= MIN_BALANCE_KOPECKS &&
cents <= 9223372036854775807n cents <= MAX_BALANCE_KOPECKS
); );
}, validationMessage("bank.validation.money")); }, validationMessage("bank.validation.money"));
const positiveMoneySchema = moneySchema.refine((value) => { const positiveMoneySchema = moneySchema.refine((value) => {
@@ -60,11 +66,17 @@ const bankCardSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, validationMessage("bank.validation.name")(undefined)) .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 bankName: z
.string() .string()
.trim() .trim()
.max(100, validationMessage("bank.validation.bankName")(undefined)), .max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z lastFour: z
.string() .string()
.refine( .refine(
@@ -25,6 +25,8 @@ import classes from "./BankForms.module.css";
import { BankOperationIcon } from "./BankOperationIcon"; import { BankOperationIcon } from "./BankOperationIcon";
import { useBankFormFocus } from "./useBankFormFocus"; import { useBankFormFocus } from "./useBankFormFocus";
const MAX_CATEGORY_NAME_LENGTH = 80;
type CategoryManagerProps = { type CategoryManagerProps = {
onClose: () => void; onClose: () => void;
onCreated?: (id: string) => void; onCreated?: (id: string) => void;
@@ -67,7 +69,8 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
.string() .string()
.trim() .trim()
.refine( .refine(
(value) => value.length > 0 && value.length <= 80, (value) =>
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
validationMessage("bank.validation.category"), validationMessage("bank.validation.category"),
) )
.refine( .refine(
+20 -10
View File
@@ -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) { function useBondSearch(query: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
@@ -8,7 +18,7 @@ function useBondSearch(query: string) {
params: { query: { query } }, params: { query: { query } },
}), }),
enabled: query.length >= 2, enabled: query.length >= 2,
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function bondQueryOptions(uid: string) { function bondQueryOptions(uid: string) {
@@ -16,7 +26,7 @@ function bondQueryOptions(uid: string) {
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}; };
} }
@@ -35,7 +45,7 @@ function useBond(identifier: string) {
params: { path: { ticker: identifier } }, params: { path: { ticker: identifier } },
}), }),
enabled: Boolean(identifier) && !isUid, enabled: Boolean(identifier) && !isUid,
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
@@ -45,7 +55,7 @@ function useBondMetrics(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondQuote(uid: string) { function useBondQuote(uid: string) {
@@ -54,7 +64,7 @@ function useBondQuote(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondCandles(uid: string, period: BondPeriod) { function useBondCandles(uid: string, period: BondPeriod) {
@@ -63,7 +73,7 @@ function useBondCandles(uid: string, period: BondPeriod) {
params: { path: { uid }, query: { period } }, params: { path: { uid }, query: { period } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondPosition(uid: string) { function useBondPosition(uid: string) {
@@ -72,7 +82,7 @@ function useBondPosition(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
function useBondCoupons(uid: string) { function useBondCoupons(uid: string) {
@@ -81,7 +91,7 @@ function useBondCoupons(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: BOND_STALE_TIME_MS,
}); });
} }
+1
View File
@@ -9,6 +9,7 @@ function formatShare(
if (!numerator || !denominator) return null; if (!numerator || !denominator) return null;
const total = Number(denominator.value); const total = Number(denominator.value);
if (!Number.isFinite(total) || total <= 0) return null; 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; const percent = (Number(numerator.value) / total) * 100;
if (!Number.isFinite(percent)) return null; if (!Number.isFinite(percent)) return null;
const rounded = new Intl.NumberFormat("ru-RU", { const rounded = new Intl.NumberFormat("ru-RU", {
+16 -9
View File
@@ -1,10 +1,17 @@
import { import {
apiQueryClient, apiQueryClient,
type paths,
useAuthenticatedQueries, useAuthenticatedQueries,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "@/shared/api"; } 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) { function useStockSearch(query: string) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
@@ -12,7 +19,7 @@ function useStockSearch(query: string) {
params: { query: { query } }, params: { query: { query } },
}), }),
enabled: query.length >= 2, enabled: query.length >= 2,
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
function stockQueryOptions(uid: string) { function stockQueryOptions(uid: string) {
@@ -20,7 +27,7 @@ function stockQueryOptions(uid: string) {
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
params: { path: { uid } }, params: { path: { uid } },
}), }),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}; };
} }
@@ -47,7 +54,7 @@ function useStock(identifier: string) {
params: { path: { ticker: identifier } }, params: { path: { ticker: identifier } },
}), }),
enabled: Boolean(identifier) && !isUid, enabled: Boolean(identifier) && !isUid,
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
return isUid ? byUid : byTicker; return isUid ? byUid : byTicker;
} }
@@ -57,7 +64,7 @@ function useStockQuote(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
function useStockCandles(uid: string, period: StockPeriod) { function useStockCandles(uid: string, period: StockPeriod) {
@@ -66,7 +73,7 @@ function useStockCandles(uid: string, period: StockPeriod) {
params: { path: { uid }, query: { period } }, params: { path: { uid }, query: { period } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
function useStockPosition(uid: string) { function useStockPosition(uid: string) {
@@ -75,7 +82,7 @@ function useStockPosition(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
function useStockCompany(uid: string) { function useStockCompany(uid: string) {
@@ -84,7 +91,7 @@ function useStockCompany(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
function useStockDividends(uid: string) { function useStockDividends(uid: string) {
@@ -93,7 +100,7 @@ function useStockDividends(uid: string) {
params: { path: { uid } }, params: { path: { uid } },
}), }),
enabled: Boolean(uid), enabled: Boolean(uid),
staleTime: 30_000, staleTime: STOCK_STALE_TIME_MS,
}); });
} }
+11 -5
View File
@@ -1,11 +1,17 @@
import { z } from "zod"; import { z } from "zod";
import { validationMessage } from "@/shared/i18n"; 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 { function isEmailCharacterInvalid(character: string): boolean {
return ( return (
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < 32 || character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === 127 character.charCodeAt(0) === ASCII_DELETE
); );
} }
@@ -13,7 +19,7 @@ const email = z
.string() .string()
.trim() .trim()
.toLowerCase() .toLowerCase()
.max(254) .max(MAX_EMAIL_LENGTH)
.refine( .refine(
(value) => (value) =>
value.split("@").length === 2 && value.split("@").length === 2 &&
@@ -25,8 +31,8 @@ const password = z
.string() .string()
.refine( .refine(
(value) => (value) =>
new TextEncoder().encode(value).length >= 12 && new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES &&
new TextEncoder().encode(value).length <= 72, new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
validationMessage("auth.validation.invalidPasswordLength"), validationMessage("auth.validation.invalidPasswordLength"),
); );
@@ -14,6 +14,9 @@ import { LoadingSkeleton } from "@/shared/ui";
import type { SectionState } from "../model/section"; import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css"; import classes from "./InstrumentDetails.module.css";
const CHART_SKELETON_HEIGHT_PX = 280;
const SECTION_SKELETON_HEIGHT_PX = 65;
type InstrumentSectionProps = PropsWithChildren<{ type InstrumentSectionProps = PropsWithChildren<{
title: string; title: string;
state: SectionState; state: SectionState;
@@ -59,7 +62,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
label={t("stock.loadingSection", { section: props.title })} label={t("stock.loadingSection", { section: props.title })}
> >
<Stack> <Stack>
<Skeleton h={props.large ? 280 : 65} /> <Skeleton
h={
props.large
? CHART_SKELETON_HEIGHT_PX
: SECTION_SKELETON_HEIGHT_PX
}
/>
<Skeleton h={14} w="60%" /> <Skeleton h={14} w="60%" />
</Stack> </Stack>
</LoadingSkeleton> </LoadingSkeleton>
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui"; import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
const AccountEventsPageLoading: FC = () => { const AccountEventsPageLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -17,8 +19,8 @@ const AccountEventsPageLoading: FC = () => {
<Skeleton height={36} width={116} /> <Skeleton height={36} width={116} />
<Skeleton height={36} width={108} /> <Skeleton height={36} width={108} />
</Group> </Group>
{[0, 1, 2, 3].map((index) => ( {skeletonRowIds.map((id) => (
<Skeleton key={index} height={82} radius="md" /> <Skeleton key={id} height={82} radius="md" />
))} ))}
</LoadingSkeleton> </LoadingSkeleton>
); );
@@ -7,6 +7,8 @@ import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n"; import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib"; import { formatDate } from "@/shared/lib";
const ISO_MONTH_LENGTH = 7;
type IncomePeriod = { from: string; to: string }; type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod; type IncomeForm = IncomePeriod;
@@ -143,7 +145,7 @@ function draftPeriod(period: IncomePeriod): IncomeForm {
function defaultPeriod(): IncomePeriod { function defaultPeriod(): IncomePeriod {
const today = moscowDate(new Date()); 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 { function validPeriod(period: IncomePeriod): period is IncomePeriod {
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui"; import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
const AccountOperationsPageLoading: FC = () => { const AccountOperationsPageLoading: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,8 +21,8 @@ const AccountOperationsPageLoading: FC = () => {
<Skeleton height={36} width={110} radius="sm" /> <Skeleton height={36} width={110} radius="sm" />
</Group> </Group>
<Stack gap="xs"> <Stack gap="xs">
{[0, 1, 2, 3].map((index) => ( {skeletonRowIds.map((id) => (
<Skeleton key={index} height={64} radius="md" /> <Skeleton key={id} height={64} radius="md" />
))} ))}
</Stack> </Stack>
</LoadingSkeleton> </LoadingSkeleton>
+3 -1
View File
@@ -17,6 +17,8 @@ import { useStocks } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
const RECENT_EVENTS_LIMIT = 5;
type PositionSectionKind = "stocks" | "bonds" | "other"; type PositionSectionKind = "stocks" | "bonds" | "other";
type AccountPageReturnTone = "positive" | "negative"; type AccountPageReturnTone = "positive" | "negative";
@@ -176,7 +178,7 @@ function toRecentEvents(
const to = `/accounts/${accountId}/events`; const to = `/accounts/${accountId}/events`;
if (events.isLoading) return { state: "loading", to }; if (events.isLoading) return { state: "loading", to };
if (events.isError) return { state: "error", 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 }; if (!items.length) return { state: "empty", to };
return { state: "loaded", to, items }; return { state: "loaded", to, items };
} }
@@ -4,6 +4,8 @@ import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { PortfolioStructureView } from "./PortfolioStructure.view"; 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. // A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null }; type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
@@ -45,7 +47,7 @@ const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
share: share:
percentage === null percentage === null
? t("portfolio.structure.shareUnavailable") ? t("portfolio.structure.shareUnavailable")
: percentage > 0 && percentage < 0.01 : percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT
? t("portfolio.structure.smallShare") ? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`, : `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
value: percentage ?? 0, value: percentage ?? 0,
+3 -1
View File
@@ -4,10 +4,12 @@ import { useBondSearch } from "@/entities/bond";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
function useBondsPageVm() { function useBondsPageVm() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const query = params.get("q") ?? ""; const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), 300); const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useBondSearch(debounced); const result = useBondSearch(debounced);
const waiting = query.trim() !== debounced; const waiting = query.trim() !== debounced;
return { return {
+6 -1
View File
@@ -26,6 +26,8 @@ import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types"; import type { OperationValues } from "./OperationForm.types";
import { OperationFields, RefundFields } from "./OperationForm.view"; import { OperationFields, RefundFields } from "./OperationForm.view";
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({ const operationSchema = z.object({
kind: z.enum(["expense", "topup", "transfer", "refund"]), kind: z.enum(["expense", "topup", "transfer", "refund"]),
cardId: z.string(), cardId: z.string(),
@@ -36,7 +38,10 @@ const operationSchema = z.object({
date: bankDateSchema, date: bankDateSchema,
description: z description: z
.string() .string()
.max(500, validationMessage("bank.validation.description")(undefined)), .max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
}); });
type OperationFormProps = { type OperationFormProps = {
+3 -1
View File
@@ -4,10 +4,12 @@ import { useStockSearch } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; import { instrumentLogoUrl } from "@/shared/lib";
const SEARCH_DEBOUNCE_MS = 300;
function useStocksPageVm() { function useStocksPageVm() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const query = params.get("q") ?? ""; const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), 300); const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useStockSearch(debounced); const result = useStockSearch(debounced);
const waiting = query.trim() !== debounced; const waiting = query.trim() !== debounced;
return { return {
+3 -1
View File
@@ -3,6 +3,8 @@ import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema"; import type { paths } from "./generated/schema";
const HTTP_UNAUTHORIZED = 401;
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000"; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
type AuthHandlers = { type AuthHandlers = {
@@ -50,7 +52,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
const retryRequest = authorized.clone(); const retryRequest = authorized.clone();
const response = await fetch(authorized); const response = await fetch(authorized);
if ( if (
response.status !== 401 || response.status !== HTTP_UNAUTHORIZED ||
isAuthRequest || isAuthRequest ||
!auth || !auth ||
( (