refactor: tighten types and reuse instrument UI
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-09-29 06:15:31 +03:00
parent b404c1802a
commit 7c28a423dd
67 changed files with 380 additions and 347 deletions
+2 -2
View File
@@ -110,8 +110,8 @@ Render-helper замени самостоятельным presentation component
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css` лежит рядом со своим view, тестом и story. Формы используют Mantine
остаётся в `bank-card/ui`. `Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
## Формы ## Формы
+9
View File
@@ -1,5 +1,14 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{
from: {
path: "^(src|packages/[^/]+/src)/",
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
},
name: "production-must-not-depend-on-test-code",
severity: "error",
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
},
{ {
from: {}, from: {},
name: "no-unresolved", name: "no-unresolved",
+3
View File
@@ -153,11 +153,13 @@ export default defineConfig(
}, },
}, },
rules: { rules: {
"@typescript-eslint/consistent-type-imports": "error",
// React ignores event handler return values; floating promises are still checked in bodies. // React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
"error", "error",
{ checksVoidReturn: { attributes: false } }, { checksVoidReturn: { attributes: false } },
], ],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error", "@typescript-eslint/switch-exhaustiveness-check": "error",
}, },
}, },
@@ -197,6 +199,7 @@ export default defineConfig(
ignoreTypeIndexes: true, ignoreTypeIndexes: true,
}, },
], ],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
}, },
}, },
{ {
+1 -4
View File
@@ -1,15 +1,12 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": [ "lib": [
"ES2023", "ES2023",
"DOM" "DOM"
], ],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
@@ -93,3 +93,13 @@
transition: none; transition: none;
} }
} }
.row[data-wrap] p {
white-space: normal;
overflow-wrap: anywhere;
}
@media (min-width: 36em) {
.row[data-wrap] {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
@@ -39,3 +39,5 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
+7 -1
View File
@@ -18,6 +18,7 @@ type EntryCardProps = {
| "positive"; | "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
@@ -31,7 +32,12 @@ const EntryCard: FC<EntryCardProps> = (props) => {
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return ( return (
<Paper className={classes.row} style={surfaceStyle} withBorder> <Paper
className={classes.row}
data-wrap={props.wrap || undefined}
style={surfaceStyle}
withBorder
>
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
+1 -4
View File
@@ -1,13 +1,10 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
+15
View File
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
const eslint = new ESLint(); const eslint = new ESLint();
for (const [filePath, code, rule] of [ for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function read(value: string) { return value?.trim(); }",
"@typescript-eslint/no-unnecessary-condition",
],
[
"src/shared/api/problem-details.ts",
"export function read(value?: string) { return value!.trim(); }",
"@typescript-eslint/no-non-null-assertion",
],
[
"src/shared/api/problem-details.ts",
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
"@typescript-eslint/consistent-type-imports",
],
[ [
"src/shared/api/problem-details.ts", "src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }", "export function run() { Promise.resolve(); }",
@@ -65,6 +65,8 @@ describe("AppRoutes", () => {
path: { pathname: "/login", state: { returnTo: "//example.com" } }, path: { pathname: "/login", state: { returnTo: "//example.com" } },
title: "Вход", title: "Вход",
}, },
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => { ])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
let credentials: unknown; let credentials: unknown;
server.use( server.use(
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
return { hasError: true }; return { hasError: true };
} }
state: State = { hasError: false }; override state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) { override componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false }); this.setState({ hasError: false });
} }
} }
render() { override render() {
return this.state.hasError return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} /> ? <RouteErrorFallback authenticated={this.props.authenticated} />
: ( : (
+5 -2
View File
@@ -30,8 +30,11 @@ const Public: FC<PropsWithChildren> = (props) => {
if (!ready) return <RouteSkeleton variant="shell" />; if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null const state: unknown = location.state;
| { returnTo?: string })?.returnTo;
const requested = state && typeof state === "object" && "returnTo" in state
? state.returnTo
: undefined;
const returnTo const returnTo
= typeof requested === "string" = typeof requested === "string"
+2 -2
View File
@@ -403,7 +403,7 @@ describe("banking API", () => {
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"), expect(result.current.categories.data?.[0]?.name).toBe("Food"),
); );
await act(() => await act(() =>
result.current.updateCategory.mutateAsync({ result.current.updateCategory.mutateAsync({
@@ -412,7 +412,7 @@ describe("banking API", () => {
}), }),
); );
await waitFor(() => await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Groceries"), expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
); );
await act(() => await act(() =>
result.current.updateOperation.mutateAsync({ result.current.updateOperation.mutateAsync({
+10 -10
View File
@@ -46,16 +46,16 @@ function useBankMutationOptions() {
return { return {
onError: (error: unknown) => { onError: (error: unknown) => {
const errorCode = apiErrorCode(error); const errorCode = apiErrorCode(error);
if (
[ return [
ApiErrorCode.BankDependentRefunds, ApiErrorCode.BankDependentRefunds,
ApiErrorCode.BankIntentConflict, ApiErrorCode.BankIntentConflict,
ApiErrorCode.BankRefundLimit, ApiErrorCode.BankRefundLimit,
ApiErrorCode.BankVersionConflict, ApiErrorCode.BankVersionConflict,
ApiErrorCode.BankWriteConflict, ApiErrorCode.BankWriteConflict,
].some(code => isApiErrorCode(errorCode, code)) ].some(code => isApiErrorCode(errorCode, code))
) ? invalidate()
return invalidate(); : undefined;
}, },
onSuccess: invalidate, onSuccess: invalidate,
retry: false, retry: false,
+2 -1
View File
@@ -143,7 +143,7 @@ function useBankOperations(
return !isAll return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length && 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
&& previous?.query?.to === query.to && previous?.query?.to === query.to
@@ -165,6 +165,7 @@ function useBankOperations(
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error; if (error) throw error;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract.
if (!data) throw new Error("Missing bank operations response"); if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data; if (!isAll) return data;
items.push(...data.items); items.push(...data.items);
@@ -1,4 +1,4 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC } from "react"; import { type FC } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
@@ -6,7 +6,6 @@ import { formatDate } from "@/shared/lib";
import { type BankCard } from "../../api/queries"; import { type BankCard } from "../../api/queries";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { BankCardFields } from "./BankCardForm.view"; import { BankCardFields } from "./BankCardForm.view";
import { useBankCardFormVm } from "./BankCardForm.vm"; import { useBankCardFormVm } from "./BankCardForm.vm";
@@ -40,7 +39,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} /> <BankCardFields archived={Boolean(props.card?.archivedAt)} />
@@ -80,7 +79,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -1,7 +1,7 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import type { Control } from "react-hook-form"; import type { Control } from "react-hook-form";
import { DateInput } from "@mantine/dates";
import { useController } from "react-hook-form"; import { useController } from "react-hook-form";
import { bankDateInputValue } from "../../model/form-validation"; import { bankDateInputValue } from "../../model/form-validation";
@@ -1,6 +0,0 @@
.fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
@@ -78,6 +78,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
</LoadingSkeleton> </LoadingSkeleton>
); );
} }
return null;
}; };
const BankSummaryView: FC<BankSummaryViewProps> = (props) => { const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
@@ -8,7 +8,7 @@ import {
waitFor, waitFor,
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
name: "Переименовать", name: "Переименовать",
}); });
assert.isDefined(renameButtons[0]);
fireEvent.click(renameButtons[0]); fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }), await screen.findByRole("button", { name: "Перечитать запись" }),
); );
await waitFor(() => expect(reads).toBe(3)); await waitFor(() => expect(reads).toBe(3));
fireEvent.click( const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
screen.getAllByRole("button", { name: "Переименовать" })[1], assert.isDefined(secondRename);
); fireEvent.click(secondRename);
fireEvent.change(screen.getByLabelText(/Название категории/), { fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" }, target: { value: "Новая вторая" },
}); });
@@ -1,6 +1,6 @@
import { import { Badge,
Badge,
Button, Button,
Fieldset,
Group, Group,
Modal, Modal,
Stack, Stack,
@@ -14,7 +14,6 @@ import { useTranslation } from "react-i18next";
import { bankOperationAppearance } from "../../model/operation-appearance"; import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback"; import { BankFeedback } from "../BankFeedback";
import classes from "../BankForms.module.css";
import { useCategoryManagerVm } from "./CategoryManager.vm"; import { useCategoryManagerVm } from "./CategoryManager.vm";
type CategoryManagerProps = { type CategoryManagerProps = {
@@ -76,7 +75,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<fieldset className={classes.fieldset} disabled={isPending}> <Fieldset disabled={isPending} variant="unstyled">
<Stack> <Stack>
{categoryRows?.map((category) => { {categoryRows?.map((category) => {
return ( return (
@@ -149,7 +148,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -34,7 +34,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(event.logoName)} imageSrc={instrumentLogoUrl(event.logoName)}
initial={( initial={(
(event.ticker ?? event.displayName).trim()[0] ?? "?" event.ticker.trim()[0] ?? "?"
).toUpperCase()} ).toUpperCase()}
metadata={metadata} metadata={metadata}
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
+2 -2
View File
@@ -345,12 +345,12 @@ describe("AuthProvider", () => {
); );
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("a-card"), expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
); );
identity = "b"; identity = "b";
await act(() => expireSession()); await act(() => expireSession());
await waitFor(() => await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("b-card"), expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
); );
}); });
}); });
@@ -1,4 +0,0 @@
.fieldset {
border: 0;
padding: 0;
}
+10 -11
View File
@@ -1,9 +1,9 @@
import type { FC } from "react"; import type { FC } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -12,7 +12,6 @@ import {
loginSchema, loginSchema,
registerSchema, registerSchema,
} from "../../model/auth.schema"; } from "../../model/auth.schema";
import classes from "./AuthForm.module.css";
type AuthFormProps = { type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>; onSubmit: (email: string, password: string) => Promise<void>;
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const schema = register ? registerSchema : loginSchema; const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({ const methods = useForm<AuthFormValues>({
resolver: zodResolver(schema) as Resolver<AuthFormValues>, resolver: zodResolver(schema),
}); });
const { const {
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const [submitError, setSubmitError] = useState<null | string>(null); const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit2 = methods.handleSubmit(async (values) => { const submit = methods.handleSubmit(async (values) => {
setSubmitError(null); setSubmitError(null);
try { try {
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={onSubmit2}> <form onSubmit={submit}>
<fieldset className={classes.fieldset} disabled={isSubmitting}> <Fieldset disabled={isSubmitting} variant="unstyled">
<Stack> <Stack>
<TextInput <TextInput
label={t("auth.email")} label={t("auth.email")}
{...methods.register("email")} {...methods.register("email")}
error={errors.email?.message as string} error={errors.email?.message}
/> />
<PasswordInput <PasswordInput
label={t("auth.password")} label={t("auth.password")}
{...methods.register("password")} {...methods.register("password")}
error={errors.password?.message as string} error={errors.password?.message}
/> />
{register && ( {register && (
<PasswordInput <PasswordInput
label={t("auth.passwordConfirmation")} label={t("auth.passwordConfirmation")}
{...methods.register("passwordConfirmation")} {...methods.register("passwordConfirmation")}
error={errors.passwordConfirmation?.message as string} error={errors.passwordConfirmation?.message}
/> />
)} )}
@@ -82,7 +81,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.haveAccount" : "auth.noAccount")} {t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button> </Button>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</FormProvider> </FormProvider>
); );
+3
View File
@@ -1,8 +1,11 @@
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";
export { default as instrumentDetailsStyles } from "./ui/InstrumentDetails.module.css";
export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
export { InstrumentMetric } from "./ui/InstrumentMetric.view";
export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPageState } from "./ui/InstrumentPageState.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view";
export { InstrumentSection } from "./ui/InstrumentSection.view"; export { InstrumentSection } from "./ui/InstrumentSection.view";
@@ -11,16 +11,18 @@ function useInstrumentParameters(incomeFilter: "coupons" | "dividends") {
value => value === parameters.get("period"), value => value === parameters.get("period"),
) ?? "month"; ) ?? "month";
const filters: { categories: OperationCategory[]; value: string }[] = [ const defaultFilter: { categories: OperationCategory[]; value: string } = { categories: [], value: "all" };
{ categories: [], value: "all" },
const filters = [
defaultFilter,
{ categories: ["trade"], value: "trades" }, { categories: ["trade"], value: "trades" },
{ categories: ["income"], value: incomeFilter }, { categories: ["income"], value: incomeFilter },
{ categories: ["tax", "fee", "transfer", "other"], value: "other" }, { categories: ["tax", "fee", "transfer", "other"], value: "other" },
]; ] satisfies { categories: OperationCategory[]; value: string }[];
const { categories, value: filter } = filters.find( const { categories, value: filter } = filters.find(
item => item.value === parameters.get("events"), item => item.value === parameters.get("events"),
) ?? filters[0]; ) ?? defaultFilter;
const setParameter = (key: string, value: string) => { const setParameter = (key: string, value: string) => {
const next = new URLSearchParams(parameters); const next = new URLSearchParams(parameters);
@@ -60,3 +60,32 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.metricsGrid > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
@media (max-width: 48em) {
.metricsGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -2,9 +2,9 @@ import type { FC } from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
type MetricProps = { label: string; value: string }; type InstrumentMetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = props => ( const InstrumentMetric: FC<InstrumentMetricProps> = props => (
<div> <div>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{props.label} {props.label}
@@ -16,4 +16,4 @@ const Metric: FC<MetricProps> = props => (
</div> </div>
); );
export { Metric }; export { InstrumentMetric };
@@ -0,0 +1,33 @@
import type { FC } from "react";
import { Badge, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime } from "@/shared/lib";
type InstrumentQuoteStatusProps = {
quote?: { priceAt: null | string; tradingStatus: null | string };
};
const InstrumentQuoteStatus: FC<InstrumentQuoteStatusProps> = (props) => {
const { quote } = props;
const { t } = useTranslation();
return (
<>
<Badge color="gray" variant="light">
{t(`stock.trading.${quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", { date: formatSnapshotTime(quote.priceAt) })}
</Text>
)}
</>
);
};
export { InstrumentQuoteStatus };
@@ -103,7 +103,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
meta ? [meta, ...(meta.dependencies ?? [])] : [], meta ? [meta, ...(meta.dependencies ?? [])] : [],
) )
.flatMap(meta => .flatMap(meta =>
meta?.sync.id meta.sync.id
&& (meta.sync.state === "running" || meta.sync.state === "queued") && (meta.sync.state === "running" || meta.sync.state === "queued")
? [meta.sync.id] ? [meta.sync.id]
: [], : [],
@@ -3,6 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router"; import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as BrokerageEvents from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event"; import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
@@ -18,7 +20,7 @@ vi.mock("@/features/tbank-connection", () => ({
}), }),
})); }));
vi.mock("@/entities/brokerage-event", async importOriginal => ({ vi.mock("@/entities/brokerage-event", async importOriginal => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()), ...(await importOriginal<typeof BrokerageEvents>()),
useBrokerageEvents: vi.fn(), useBrokerageEvents: vi.fn(),
})); }));
+3 -2
View File
@@ -156,8 +156,9 @@ function toIncomeDisplayModel(
return null; return null;
} }
const previous = meta?.dependencies?.find(({ role }) => role === "previous"); const dependencies = meta?.dependencies ?? [];
const year = meta?.dependencies?.find(({ role }) => role === "year"); const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
return { return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison), comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
@@ -78,7 +78,7 @@ const analytics
}, },
totalsByCurrency: [summary("RUB", "10", "2")], totalsByCurrency: [summary("RUB", "10", "2")],
yearComparison: null, yearComparison: null,
} satisfies IncomeAnalytics; } satisfies IncomeAnalytics & { instruments: [unknown] };
function comparison(currency: string) { function comparison(currency: string) {
return { return {
@@ -11,7 +11,7 @@ import {
useLocation, useLocation,
useNavigate, useNavigate,
} from "react-router"; } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { assert, describe, expect, it, vi } from "vitest";
import { import {
type BrokerageOperation, type BrokerageOperation,
@@ -264,8 +264,8 @@ describe("AccountOperationsPage", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByLabelText("Период"); await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull(); expect(searches[0]?.get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull(); expect(searches[0]?.get("to")).toBeNull();
for (const expected of [ for (const expected of [
"?categories=trade", "?categories=trade",
@@ -597,6 +597,8 @@ it("recovers an invalid cursor at the first page without clearing applied filter
fireEvent.click(await screen.findByRole("button", { name: "Далее" })); fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => expect(searches).toHaveLength(3)); await waitFor(() => expect(searches).toHaveLength(3));
assert.isDefined(searches[0]);
assert.isDefined(searches[2]);
expect(searches[2].get("cursor")).toBeNull(); expect(searches[2].get("cursor")).toBeNull();
for (const key of ["from", "to", "categories"]) for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key)); expect(searches[2].get(key)).toBe(searches[0].get(key));
@@ -1,6 +1,6 @@
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
const portfolio: Portfolio const portfolio
= { = {
cashBalances: [{ currency: "USD", value: "1" }], cashBalances: [{ currency: "USD", value: "1" }],
isStale: true, isStale: true,
@@ -42,10 +42,10 @@ const portfolio: Portfolio
ticker: null, ticker: null,
type: "FUND", type: "FUND",
}, },
], ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "150" }], totalsByCurrency: [{ currency: "RUB", value: "150" }],
}; } satisfies Portfolio;
const position: Portfolio["positions"][number] = { const position: Portfolio["positions"][number] = {
...portfolio.positions[0], ...portfolio.positions[0],
+11 -11
View File
@@ -59,13 +59,13 @@ describe("account position sections", () => {
"bonds", "bonds",
"other", "other",
]); ]);
expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([
"FIGI-FUND", "FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`), ...otherTypes.map(type => `FIGI-${type}-NEXT`),
]); ]);
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original); expect(snapshot).toEqual(original);
}); });
@@ -125,10 +125,10 @@ describe("account position sections", () => {
figi: null, figi: null,
}, },
{ ...second, figi: null }, { ...second, figi: null },
])[0].groups, ])[0]?.groups,
).toHaveLength(1); ).toHaveLength(1);
expect( expect(
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups,
).toHaveLength(2); ).toHaveLength(2);
expect( expect(
@@ -143,19 +143,19 @@ describe("account position sections", () => {
figi: null, figi: null,
isin: null, isin: null,
}, },
])[0].groups, ])[0]?.groups,
).toHaveLength(2); ).toHaveLength(2);
}); });
it("preserves one-account cards and does not merge repeated rows on the same account", () => { it("preserves one-account cards and does not merge repeated rows on the same account", () => {
expect( expect(
sections([position, { ...second, account: position.account }])[0].groups, sections([position, { ...second, account: position.account }])[0]?.groups,
).toHaveLength(2); ).toHaveLength(2);
expect( expect(
sections([ sections([
{ ...position, account: undefined }, { ...position, account: undefined },
{ ...second, account: undefined }, { ...second, account: undefined },
])[0].groups, ])[0]?.groups,
).toHaveLength(2); ).toHaveLength(2);
}); });
@@ -170,9 +170,9 @@ describe("account position sections", () => {
]; ];
const before = structuredClone(source); const before = structuredClone(source);
const [group] = sections(source)[0].groups; const group = sections(source)[0]?.groups[0];
expect( expect(
group.positions.map(item => [ group?.positions.map(item => [
item.quantity, item.quantity,
item.currentValue, item.currentValue,
item.averagePurchasePrice, item.averagePurchasePrice,
+1 -1
View File
@@ -11,7 +11,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
type AccountPagePositionGroup = { type AccountPagePositionGroup = {
key: string; key: string;
positions: AccountPagePosition[]; positions: [AccountPagePosition, ...AccountPagePosition[]];
}; };
type AccountPagePositionSection = { type AccountPagePositionSection = {
@@ -67,7 +67,7 @@ function renderPage(ui: React.ReactNode) {
); );
} }
const portfolio: Portfolio const portfolio
= { = {
cashBalances: [{ currency: "USD", value: "0.5" }], cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true, isStale: true,
@@ -97,10 +97,10 @@ const portfolio: Portfolio
ticker: null, ticker: null,
type: "BOND", type: "BOND",
}, },
], ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { currencies: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
}; } satisfies Portfolio;
function position( function position(
overrides: Partial<Portfolio["positions"][number]>, overrides: Partial<Portfolio["positions"][number]>,
@@ -1,7 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { toAccountPagePositionSections } from "../../model/positions"; import { toAccountPagePositionSections } from "../../model/positions";
import { portfolio, position, second } from "../../model/positions.fixtures"; import { portfolio, position, second } from "../../model/positions.fixtures";
@@ -13,6 +13,8 @@ describe("Positions grouped by security", () => {
...portfolio, positions: [position, second], ...portfolio, positions: [position, second],
})[0]; })[0];
assert.isDefined(section);
render( render(
<MantineProvider> <MantineProvider>
<MemoryRouter> <MemoryRouter>
@@ -27,6 +29,8 @@ describe("Positions grouped by security", () => {
).getAllByRole("listitem"); ).getAllByRole("listitem");
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
assert.isDefined(rows[0]);
assert.isDefined(rows[1]);
expect( expect(
within(rows[0]).getByRole("link", { name: "Основной" }), within(rows[0]).getByRole("link", { name: "Основной" }),
).toHaveAttribute("href", "/accounts/a"); ).toHaveAttribute("href", "/accounts/a");
@@ -6,7 +6,7 @@ import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup. // eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { afterEach, expect, it } from "vitest"; import { afterEach, assert, expect, it } from "vitest";
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions"; import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
@@ -76,6 +76,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
expect(current.height, context).toBeGreaterThan(0); expect(current.height, context).toBeGreaterThan(0);
if (index === 0) continue; if (index === 0) continue;
const previous = bounds[index - 1]; const previous = bounds[index - 1];
assert.isDefined(previous);
if (kind === "other" && width === 1280) { if (kind === "other" && width === 1280) {
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -1,33 +0,0 @@
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.companyMetrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.embeddedSection {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-lg);
border-top: 1px solid var(--wl-color-border);
}
.companyMetrics > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
@media (max-width: 48em) {
.companyMetrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+17 -29
View File
@@ -14,23 +14,23 @@ import { useTranslation } from "react-i18next";
import { import {
InstrumentChart, InstrumentChart,
instrumentDetailsStyles,
InstrumentEvents, InstrumentEvents,
InstrumentHeader, InstrumentHeader,
InstrumentMetric,
InstrumentPosition, InstrumentPosition,
InstrumentQuoteStatus,
} from "@/features/instrument-details"; } from "@/features/instrument-details";
import { import {
formatDate, formatDate,
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotTime,
} from "@/shared/lib"; } from "@/shared/lib";
import type { BondPageVm } from "./BondPage.vm"; import type { BondPageVm } from "./BondPage.vm";
import classes from "./BondPage.module.css";
import { BondCoupons } from "./components/BondCoupons.view"; import { BondCoupons } from "./components/BondCoupons.view";
import { BondMetrics } from "./components/BondMetrics.view"; import { BondMetrics } from "./components/BondMetrics.view";
import { Metric } from "./components/Metric.view";
type BondPageViewProps = { vm: BondPageVm }; type BondPageViewProps = { vm: BondPageVm };
@@ -75,7 +75,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
{t("bond.percentOfNominal")} {t("bond.percentOfNominal")}
</Text> </Text>
<Text className={classes.price}> <Text className={instrumentDetailsStyles.price}>
{vm.quote?.pricePercent == undefined {vm.quote?.pricePercent == undefined
? t("bond.unknown") ? t("bond.unknown")
: `${formatDecimal(vm.quote.pricePercent)} %`} : `${formatDecimal(vm.quote.pricePercent)} %`}
@@ -97,17 +97,17 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Text> </Text>
<Group gap="xl" my="sm"> <Group gap="xl" my="sm">
<Metric <InstrumentMetric
label={t("bond.cleanPrice")} label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice)} value={formatMoney(vm.quote?.cleanPrice)}
/> />
<Metric <InstrumentMetric
label={t("bond.aci")} label={t("bond.aci")}
value={formatMoney(vm.quote?.aci)} value={formatMoney(vm.quote?.aci)}
/> />
<Metric <InstrumentMetric
label={t("bond.dirtyPrice")} label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice)} value={formatMoney(vm.quote?.dirtyPrice)}
/> />
@@ -118,19 +118,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Text> </Text>
<Group> <Group>
<Badge color="gray" variant="light"> <InstrumentQuoteStatus quote={vm.quote} />
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!vm.quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
)}
</Group> </Group>
{!!vm.quote?.unavailable.length && ( {!!vm.quote?.unavailable.length && (
@@ -143,7 +131,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
} }
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={instrumentDetailsStyles.page} gap="lg">
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={bond.name} name={bond.name}
@@ -180,29 +168,29 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
</Text> </Text>
)} )}
<div className={classes.embeddedSection}> <div className={instrumentDetailsStyles.embeddedSection}>
<Title mb="md" order={2} size="h3"> <Title mb="md" order={2} size="h3">
{t("bond.terms")} {t("bond.terms")}
</Title> </Title>
<div className={classes.companyMetrics}> <div className={instrumentDetailsStyles.metricsGrid}>
<Metric <InstrumentMetric
label={t("bond.nominal")} label={t("bond.nominal")}
value={formatMoney(bond.nominal)} value={formatMoney(bond.nominal)}
/> />
<Metric <InstrumentMetric
label={t("bond.initialNominal")} label={t("bond.initialNominal")}
value={formatMoney(bond.initialNominal)} value={formatMoney(bond.initialNominal)}
/> />
<Metric label={t("bond.maturity")} value={maturityDate} /> <InstrumentMetric label={t("bond.maturity")} value={maturityDate} />
<Metric label={t("bond.callDate")} value={callDate} /> <InstrumentMetric label={t("bond.callDate")} value={callDate} />
<Metric label={t("bond.couponFrequency")} value={couponFrequency} /> <InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} />
<Metric label={t("bond.lot")} value={lotSize} /> <InstrumentMetric label={t("bond.lot")} value={lotSize} />
</div> </div>
</div> </div>
</InstrumentHeader> </InstrumentHeader>
@@ -5,11 +5,9 @@ import { useTranslation } from "react-i18next";
import type { BondCoupons as Coupons } from "@/entities/bond"; import type { BondCoupons as Coupons } from "@/entities/bond";
import { InstrumentSection, type SectionState } from "@/features/instrument-details"; import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import { Metric } from "./Metric.view";
type BondCouponsProps = { type BondCouponsProps = {
coupons?: Coupons; coupons?: Coupons;
pastCoupons: Coupons["coupons"]; pastCoupons: Coupons["coupons"];
@@ -116,10 +114,10 @@ const Coupon: FC<CouponProps> = (props) => {
</Text> </Text>
</div> </div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} /> <InstrumentMetric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{!!coupon.estimatedAmount && ( {!!coupon.estimatedAmount && (
<Metric <InstrumentMetric
label={t("stock.yourEstimate")} label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)} value={formatMoney(coupon.estimatedAmount)}
/> />
@@ -5,12 +5,9 @@ import { useTranslation } from "react-i18next";
import type { BondMetrics as Metrics } from "@/entities/bond"; import type { BondMetrics as Metrics } from "@/entities/bond";
import { InstrumentSection, type SectionState } from "@/features/instrument-details"; import { instrumentDetailsStyles, InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../BondPage.module.css";
import { Metric } from "./Metric.view";
type BondMetricsProps = { metrics?: Metrics; state: SectionState }; type BondMetricsProps = { metrics?: Metrics; state: SectionState };
const BondMetrics: FC<BondMetricsProps> = (props) => { const BondMetrics: FC<BondMetricsProps> = (props) => {
@@ -32,17 +29,17 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
} }
const couponDate = metrics?.couponDate const couponDate = metrics?.couponDate
? formatDate(metrics?.couponDate) ? formatDate(metrics.couponDate)
: t("bond.unknown"); : t("bond.unknown");
const effectiveYield = metrics?.effectiveYield const effectiveYield = metrics?.effectiveYield
? `${formatDecimal(metrics?.effectiveYield.value)} %` ? `${formatDecimal(metrics.effectiveYield.value)} %`
: t("bond.unknown"); : t("bond.unknown");
const couponYield const couponYield
= metrics?.couponYieldPercent == undefined = metrics?.couponYieldPercent == undefined
? t("bond.unknown") ? t("bond.unknown")
: `${formatDecimal(metrics?.couponYieldPercent)} %`; : `${formatDecimal(metrics.couponYieldPercent)} %`;
let yieldContent; let yieldContent;
@@ -50,13 +47,13 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
yieldContent = ( yieldContent = (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("bond.yieldSource", { {t("bond.yieldSource", {
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt), at: formatSnapshotTime(metrics.effectiveYield.tradeAt),
date: formatDate(metrics?.effectiveYield.date), date: formatDate(metrics.effectiveYield.date),
price: formatDecimal( price: formatDecimal(
metrics?.effectiveYield.pricePercent, metrics.effectiveYield.pricePercent,
), ),
target: t( target: t(
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`, `bond.yieldTargets.${metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") }, { defaultValue: t("bond.yieldTargets.unknown") },
), ),
})} })}
@@ -75,26 +72,26 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
<InstrumentSection state={state} title={t("bond.metrics")}> <InstrumentSection state={state} title={t("bond.metrics")}>
{!!metrics && ( {!!metrics && (
<Stack gap="md"> <Stack gap="md">
<div className={classes.companyMetrics}> <div className={instrumentDetailsStyles.metricsGrid}>
<Metric label={t("bond.creditRating")} value={rating} /> <InstrumentMetric label={t("bond.creditRating")} value={rating} />
<Metric <InstrumentMetric
label={t("bond.couponAmount")} label={t("bond.couponAmount")}
value={formatMoney(metrics.coupon)} value={formatMoney(metrics.coupon)}
/> />
<Metric <InstrumentMetric
label={t("bond.couponType")} label={t("bond.couponType")}
value={t(`bond.couponTypes.${metrics.couponType}`, { value={t(`bond.couponTypes.${metrics.couponType}`, {
defaultValue: t("bond.unknown"), defaultValue: t("bond.unknown"),
})} })}
/> />
<Metric label={t("bond.couponDate")} value={couponDate} /> <InstrumentMetric label={t("bond.couponDate")} value={couponDate} />
<Metric label={t("bond.effectiveYield")} value={effectiveYield} /> <InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} />
<Metric label={t("bond.couponYield")} value={couponYield} /> <InstrumentMetric label={t("bond.couponYield")} value={couponYield} />
</div> </div>
{!!metrics.creditRating && ( {!!metrics.creditRating && (
@@ -128,10 +125,10 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
{t("bond.couponYieldNote")} {t("bond.couponYieldNote")}
</Text> </Text>
{!!metrics?.couponYieldPriceAt && ( {!!metrics.couponYieldPriceAt && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
{t("bond.couponYieldPriceAt", { {t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(metrics?.couponYieldPriceAt), date: formatSnapshotTime(metrics.couponYieldPriceAt),
})} })}
</Text> </Text>
)} )}
+1 -1
View File
@@ -20,7 +20,7 @@ function useBondsPageVm() {
result.data?.map(bond => ({ result.data?.map(bond => ({
...bond, ...bond,
logoSrc: instrumentLogoUrl(bond.logoName), logoSrc: instrumentLogoUrl(bond.logoName),
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`, to: `/bonds/${encodeURIComponent(bond.slug)}`,
})) ?? [], })) ?? [],
loading: loading:
canSearch canSearch
@@ -32,19 +32,6 @@
.operation { .operation {
min-width: 0; min-width: 0;
} }
.operation p {
white-space: normal;
overflow-wrap: anywhere;
}
.operation > div > div {
min-width: 0;
}
@media (min-width: 36em) {
.operation > div:first-child {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
.operations[data-pending] { .operations[data-pending] {
opacity: 0.55; opacity: 0.55;
} }
+4 -1
View File
@@ -102,12 +102,13 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
)} )}
/> />
)} )}
initial={row.title[0]} initial={row.title.charAt(0)}
metadata={row.metadata} metadata={row.metadata}
title={row.title} title={row.title}
tone={row.tone} tone={row.tone}
value={row.value} value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)} valueLabel={t(`bank.kinds.${row.item.kind}`)}
wrap
/> />
</section> </section>
); );
@@ -128,6 +129,8 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
</LoadingSkeleton> </LoadingSkeleton>
); );
} }
return null;
}; };
const CardPageView: FC<CardPageViewProps> = (props) => { const CardPageView: FC<CardPageViewProps> = (props) => {
@@ -1,6 +1,6 @@
import type { Pagination } from "@mantine/core";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { Pagination } from "@mantine/core";
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -1,6 +1,6 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form"; import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -1,6 +0,0 @@
.fieldset {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
}
@@ -90,7 +90,7 @@ function createOperationSchema({
&& !categories?.some( && !categories?.some(
c => c =>
c.id c.id
=== (values.categoryId || categories?.find(c => c.isDefault)?.id), === (values.categoryId || categories.find(c => c.isDefault)?.id),
) )
) )
context.addIssue({ context.addIssue({
@@ -467,7 +467,7 @@ describe("OperationForm", () => {
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150"); expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(bodies).toHaveLength(2)); await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[1].version).toBe(1); expect(bodies[1]?.version).toBe(1);
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "Использовать актуальную версию 2" }), screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
); );
@@ -1,7 +1,7 @@
import type { FC, ReactNode, SubmitEventHandler } from "react"; import type { FC, ReactNode, SubmitEventHandler } from "react";
import { import { Button,
Button, Fieldset,
Group, Group,
Modal, Modal,
Select, Select,
@@ -20,7 +20,6 @@ import { formatMoney } from "@/shared/lib";
import type { Choice, OperationFieldsProps } from "./OperationForm.types"; import type { Choice, OperationFieldsProps } from "./OperationForm.types";
import { useOperationFieldsVm } from "./OperationFields.vm"; import { useOperationFieldsVm } from "./OperationFields.vm";
import classes from "./OperationForm.module.css";
import { useRefundFieldsVm } from "./RefundFields.vm"; import { useRefundFieldsVm } from "./RefundFields.vm";
const OperationFields: FC<OperationFieldsProps> = (props) => { const OperationFields: FC<OperationFieldsProps> = (props) => {
@@ -203,7 +202,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
title={displayTitle} title={displayTitle}
> >
<form onSubmit={props.onSubmit}> <form onSubmit={props.onSubmit}>
<fieldset className={classes.fieldset} disabled={props.pending}> <Fieldset disabled={props.pending} variant="unstyled">
<Stack> <Stack>
<OperationFields {...props.fields} /> <OperationFields {...props.fields} />
@@ -283,7 +282,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</fieldset> </Fieldset>
</form> </form>
</Modal> </Modal>
); );
@@ -9,7 +9,7 @@ import {
} from "@testing-library/react"; } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
@@ -62,9 +62,9 @@ describe("CardsPage", () => {
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => { it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
renderCards(); renderCards();
await screen.findByRole("heading", { name: "Добавьте первую карту" }); await screen.findByRole("heading", { name: "Добавьте первую карту" });
fireEvent.click( const creationButton = screen.getAllByRole("button", { name: "Добавить карту" })[0];
screen.getAllByRole("button", { name: "Добавить карту" })[0], assert.isDefined(creationButton);
); fireEvent.click(creationButton);
const dialog = screen.getByRole("dialog"); const dialog = screen.getByRole("dialog");
const submitted: Record<string, unknown>[] = []; const submitted: Record<string, unknown>[] = [];
let resolve: (() => void) | undefined; let resolve: (() => void) | undefined;
@@ -80,7 +80,7 @@ describe("CardsPage", () => {
return HttpResponse.error(); return HttpResponse.error();
} }
return HttpResponse.json({ id: submitted[0].id }, { status: 201 }); return HttpResponse.json({ id: submitted[0]?.id }, { status: 201 });
}), }),
); );
fireEvent.change(within(dialog).getByLabelText(/Название карты/), { fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
@@ -75,6 +75,8 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
</LoadingSkeleton> </LoadingSkeleton>
); );
} }
return null;
}; };
const CardsPageView: FC<CardsPageViewProps> = (props) => { const CardsPageView: FC<CardsPageViewProps> = (props) => {
@@ -1,39 +1,11 @@
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.positive { .positive {
color: var(--mantine-color-green-8); color: var(--wl-color-positive);
} }
.negative { .negative {
color: var(--mantine-color-red-8); color: var(--wl-color-negative);
} }
.dividend { .dividend {
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
background: var(--mantine-color-green-0); background: var(--mantine-color-green-0);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.companyMetrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.companyMetrics > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
@media (max-width: 48em) {
.companyMetrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+26 -36
View File
@@ -2,7 +2,6 @@ import type { FC } from "react";
import { import {
Alert, Alert,
Badge,
Button, Button,
Group, Group,
Skeleton, Skeleton,
@@ -13,7 +12,10 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
instrumentDetailsStyles,
InstrumentHeader, InstrumentHeader,
InstrumentMetric,
InstrumentQuoteStatus,
InstrumentChart as StockChart, InstrumentChart as StockChart,
InstrumentEvents as StockEvents, InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition, InstrumentPosition as StockPosition,
@@ -23,7 +25,6 @@ import {
formatDate, formatDate,
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotTime,
} from "@/shared/lib"; } from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm"; import type { StockPageVm } from "./StockPage.vm";
@@ -31,6 +32,16 @@ import type { StockPageVm } from "./StockPage.vm";
import { StockDividends } from "./components/StockDividends.view"; import { StockDividends } from "./components/StockDividends.view";
import classes from "./StockPage.module.css"; import classes from "./StockPage.module.css";
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "compact",
});
const metricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "standard",
});
type StockPageViewProps = { vm: StockPageVm }; type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => { const StockPageView: FC<StockPageViewProps> = (props) => {
@@ -64,13 +75,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
else { else {
quoteContent = ( quoteContent = (
<div aria-live="polite"> <div aria-live="polite">
<Text className={classes.price} mt="lg"> <Text className={instrumentDetailsStyles.price} mt="lg">
{formatMoney(vm.quote?.price)} {formatMoney(vm.quote?.price)}
</Text> </Text>
{!!vm.quote?.change && ( {!!vm.quote?.change && (
<Text className={appearanceClass} fw={600}> <Text className={appearanceClass} fw={600}>
{vm.quote?.change?.value.startsWith("-") ? "" : "+"} {vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)} {formatMoney(vm.quote.change)}
@@ -95,19 +106,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
)} )}
<Group gap="sm" mt="sm"> <Group gap="sm" mt="sm">
<Badge color="gray" variant="light"> <InstrumentQuoteStatus quote={vm.quote} />
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!vm.quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
)}
</Group> </Group>
{!!vm.quote?.unavailable.length && ( {!!vm.quote?.unavailable.length && (
@@ -130,25 +129,16 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
if (vm.company) { if (vm.company) {
if (vm.company.metrics.length > 0) { if (vm.company.metrics.length > 0) {
companyMetricsContent = ( companyMetricsContent = (
<div className={classes.companyMetrics}> <div className={instrumentDetailsStyles.metricsGrid}>
{vm.company.metrics.map(metric => ( {vm.company.metrics.map((metric) => {
<div key={metric.key}> const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
<Text c="dimmed" size="xs"> const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
{t(`stock.metrics.${metric.key}`)} const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
{metric.period ? ` · ${metric.period}` : ""} return (
</Text> <InstrumentMetric key={metric.key} label={label} value={value} />
);
<Text fw={600} size="lg"> })}
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div> </div>
); );
} }
@@ -158,7 +148,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
} }
return ( return (
<Stack className={classes.page} gap="lg"> <Stack className={instrumentDetailsStyles.page} gap="lg">
<InstrumentHeader <InstrumentHeader
logoSrc={vm.logoSrc} logoSrc={vm.logoSrc}
name={stock.name} name={stock.name}
@@ -20,7 +20,7 @@ function useStocksPageVm() {
result.data?.map(stock => ({ result.data?.map(stock => ({
...stock, ...stock,
logoSrc: instrumentLogoUrl(stock.logoName), logoSrc: instrumentLogoUrl(stock.logoName),
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`, to: `/stocks/${encodeURIComponent(stock.slug)}`,
})) ?? [], })) ?? [],
loading: loading:
canSearch canSearch
+3 -3
View File
@@ -51,7 +51,7 @@ describe("useAuthenticatedQuery", () => {
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled(); expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle"); expect(result.current?.fetchStatus).toBe("idle");
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
@@ -59,9 +59,9 @@ describe("useAuthenticatedQuery", () => {
refresh: () => Promise.resolve(true), refresh: () => Promise.resolve(true),
}); });
await waitFor(() => expect(result.current.isSuccess).toBe(true)); await waitFor(() => expect(result.current?.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce(); expect(requests).toHaveBeenCalledOnce();
expect(result.current.data?.data).toEqual([ expect(result.current?.data?.data).toEqual([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
+8 -6
View File
@@ -9,13 +9,15 @@ function isValidPeriod(from: null | string, to: null | string) {
); );
} }
const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
});
function moscowDate(value: Date): string { function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", { const parts = moscowFormatter.formatToParts(value);
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).formatToParts(value);
const part = (type: string) => const part = (type: string) =>
parts.find(item => item.type === type)?.value; parts.find(item => item.type === type)?.value;
+20 -16
View File
@@ -7,17 +7,19 @@ type FormatMoneyOptions = {
type Money = { currency: string; value: string }; type Money = { currency: string; value: string };
const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
timeZone: "UTC",
year: "numeric",
});
function formatDate(value: string): string { function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { return dateFormatter.format(new Date(value));
day: "2-digit",
month: "2-digit",
timeZone: "UTC",
year: "numeric",
}).format(new Date(value));
} }
function formatDecimal(value: string): string { function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".", DECIMAL_PART_COUNT); const [integer = "", fraction] = value.split(".", DECIMAL_PART_COUNT);
const sign = integer.startsWith("-") ? "-" : ""; const sign = integer.startsWith("-") ? "-" : "";
const digits = sign ? integer.slice(1) : integer; const digits = sign ? integer.slice(1) : integer;
const groupSize = 3; const groupSize = 3;
@@ -41,7 +43,7 @@ function formatMoney(
): string { ): string {
if (!money) return "—"; if (!money) return "—";
const [integerPart, fractionPart = ""] = money.value.split( const [integerPart = "", fractionPart = ""] = money.value.split(
".", ".",
DECIMAL_PART_COUNT, DECIMAL_PART_COUNT,
); );
@@ -59,20 +61,22 @@ function formatMoney(
return `${formatDecimal(decimal)} ${currency}`; return `${formatDecimal(decimal)} ${currency}`;
} }
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
timeStyle: "short",
timeZone: "UTC",
});
function formatSnapshotTime(value: string): string { function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { return snapshotFormatter.format(new Date(value));
dateStyle: "short",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
} }
const logoSize = 160; const logoSize = 160;
const chartFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 });
function formatChartValue(value: number) { function formatChartValue(value: number) {
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format( return chartFormatter.format(value);
value,
);
} }
function instrumentLogoUrl(logoName?: null | string): null | string { function instrumentLogoUrl(logoName?: null | string): null | string {
+2 -3
View File
@@ -61,14 +61,13 @@ const ColorsView: FC = () => {
))} ))}
</SimpleGrid> </SimpleGrid>
{Object.keys(designTokens.color.palettes).map(palette => ( {Object.entries(designTokens.color.palettes).map(([palette, colors]) => (
<Stack gap="xs" key={palette}> <Stack gap="xs" key={palette}>
<Title order={2}>{palette}</Title> <Title order={2}>{palette}</Title>
<SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}> <SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}>
{ {
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. colors.map((value, shade) => (
theme.colors[palette].map((value, shade) => (
<Paper key={value} p="xs" withBorder> <Paper key={value} p="xs" withBorder>
<Box aria-hidden="true" bg={value} h={40} /> <Box aria-hidden="true" bg={value} h={40} />
+28 -12
View File
@@ -1,14 +1,22 @@
import tokens from "@wealthledger/tokens/tokens.json"; import tokens from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
function luminance(color: string) { function luminance(color: string) {
const channels = [1, 3, 5] let result = 0;
.map(index => Number.parseInt(color.slice(index, index + 2), 16) / 255)
.map(value =>
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
);
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; for (const [index, weight] of [0.2126, 0.7152, 0.0722].entries()) {
const channel = Number.parseInt(
color.slice(1 + index * 2, 3 + index * 2), 16,
) / 255;
const linear = channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
result += linear * weight;
}
return result;
} }
describe("semantic text contrast", () => { describe("semantic text contrast", () => {
@@ -24,6 +32,8 @@ describe("semantic text contrast", () => {
const foreground const foreground
= palettes[role.color as keyof typeof palettes][role.shade]; = palettes[role.color as keyof typeof palettes][role.shade];
assert.isDefined(foreground);
const surfaces = [ const surfaces = [
"#ffffff", "#ffffff",
palettes.gray[0], palettes.gray[0],
@@ -34,25 +44,31 @@ describe("semantic text contrast", () => {
...Object.entries(palettes) ...Object.entries(palettes)
.filter(([name]) => name !== "dark") .filter(([name]) => name !== "dark")
.map( .map(
([, palette]) => ([, palette]) => {
`#${[1, 3, 5] const base = palette[0];
assert.isDefined(base);
return `#${[1, 3, 5]
.map(index => .map(index =>
Math.round( Math.round(
Number.parseInt(palette[0].slice(index, index + 2), 16) Number.parseInt(base.slice(index, index + 2), 16)
* 0.45 * 0.45
+ 255 * 0.55, + 255 * 0.55,
) )
.toString(16) .toString(16)
.padStart(2, "0"), .padStart(2, "0"),
) )
.join("")}`, .join("")}`;
},
), ),
]; ];
for (const background of surfaces) for (const background of surfaces) {
assert.isDefined(background);
expect( expect(
(luminance(background) + 0.05) / (luminance(foreground) + 0.05), (luminance(background) + 0.05) / (luminance(foreground) + 0.05),
).toBeGreaterThanOrEqual(4.5); ).toBeGreaterThanOrEqual(4.5);
}
}, },
); );
}); });
+2 -8
View File
@@ -1,7 +1,7 @@
{ {
"extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "ES2024.Promise", "DOM"], "lib": ["ES2023", "ES2024.Promise", "DOM"],
"module": "esnext", "module": "esnext",
"baseUrl": ".", "baseUrl": ".",
@@ -10,18 +10,12 @@
}, },
"types": ["vite/client"], "types": ["vite/client"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true,
"strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx"
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}, },
"include": ["src", ".storybook/preview.tsx", "packages/*/src"] "include": ["src", ".storybook/preview.tsx", "packages/*/src"]
} }
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedSideEffectImports": true
}
}
+2 -8
View File
@@ -1,24 +1,18 @@
{ {
"extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"], "lib": ["ES2023"],
"types": ["node"], "types": ["node"],
"skipLibCheck": true,
"strict": true,
/* Bundler mode */ /* Bundler mode */
"module": "nodenext", "module": "nodenext",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"noUnusedLocals": true, "erasableSyntaxOnly": true
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
}, },
"include": [ "include": [
"vite.config.ts", "vite.config.ts",