diff --git a/.dockerignore b/.dockerignore
index 0c527df..bdbb86c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -22,13 +22,7 @@
!packages/ui/vite.config.ts
!packages/ui/tsconfig.json
!packages/ui/src/
-!packages/ui/src/theme.ts
-!packages/ui/src/styles.css
-!packages/ui/src/index.ts
-!packages/ui/src/StateView.tsx
-!packages/ui/src/LoadingSkeleton.tsx
-!packages/ui/src/EntryCard.tsx
-!packages/ui/src/EntryCard.module.css
+!packages/ui/src/**
!packages/icons/
!packages/icons/package.json
!packages/icons/tsconfig.json
@@ -40,3 +34,12 @@
!packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx
+
+# Keep local dependencies, outputs and secrets out of allowed source trees.
+**/node_modules
+**/dist
+**/coverage
+**/storybook-static
+**/.env*
+**/*.pem
+**/*.key
diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs
index a2d1217..2275c4b 100644
--- a/dependency-cruiser.cjs
+++ b/dependency-cruiser.cjs
@@ -1,5 +1,11 @@
module.exports = {
forbidden: [
+ {
+ name: "no-unresolved",
+ severity: "error",
+ from: {},
+ to: { couldNotResolve: true },
+ },
{
name: "packages-must-not-depend-on-app",
severity: "error",
@@ -57,7 +63,11 @@ module.exports = {
options: {
exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" },
- tsConfig: { fileName: "tsconfig.app.json" },
+ tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
+ enhancedResolveOptions: {
+ exportsFields: ["exports"],
+ conditionNames: ["import", "require", "node", "default"],
+ },
tsPreCompilationDeps: true,
},
};
diff --git a/package.json b/package.json
index b8ec252..275132b 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
"openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
- "verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
+ "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky",
"preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
diff --git a/packages/tokens/tokens.json b/packages/tokens/tokens.json
index 4f5d284..c0b43fc 100644
--- a/packages/tokens/tokens.json
+++ b/packages/tokens/tokens.json
@@ -131,7 +131,7 @@
},
"muted": {
"color": "gray",
- "shade": 6,
+ "shade": 7,
"description": "Вторичный текст; проверяйте контраст при выборе фона"
},
"border": {
diff --git a/packages/ui/src/EntryCard/EntryCard.test.tsx b/packages/ui/src/EntryCard/EntryCard.test.tsx
index 213e3d1..2d0bbcf 100644
--- a/packages/ui/src/EntryCard/EntryCard.test.tsx
+++ b/packages/ui/src/EntryCard/EntryCard.test.tsx
@@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard";
describe("EntryCard", () => {
- it("keeps the fallback after an image fails", () => {
+ it("keeps a failed image fallback until a new source is supplied", () => {
const props = {
title: "Пример",
metadata: "Описание",
@@ -31,5 +31,15 @@ describe("EntryCard", () => {
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
+ rerender(
+
+
+ ,
+ );
+ expect(container.querySelector("img")).toHaveAttribute(
+ "src",
+ "/replacement-logo.png",
+ );
+ expect(screen.queryByText("У")).not.toBeInTheDocument();
});
});
diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx
index 455e2c4..535f0aa 100644
--- a/packages/ui/src/EntryCard/EntryCard.tsx
+++ b/packages/ui/src/EntryCard/EntryCard.tsx
@@ -1,5 +1,5 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
-import { type FC, type ReactNode, useState } from "react";
+import type { FC, ReactNode } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
@@ -17,7 +17,6 @@ type EntryCardProps = {
};
const EntryCard: FC = (props) => {
- const [imageFailed, setImageFailed] = useState(false);
return (
= (props) => {
aria-hidden="true"
radius="md"
size={40}
- src={imageFailed ? null : props.imageSrc}
- onError={() => setImageFailed(true)}
+ src={props.imageSrc}
>
{props.icon ?? props.initial}
diff --git a/packages/ui/src/theme.test.ts b/packages/ui/src/theme.test.ts
index e689f76..08476de 100644
--- a/packages/ui/src/theme.test.ts
+++ b/packages/ui/src/theme.test.ts
@@ -25,5 +25,8 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length,
);
+ expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
+ variables["--wl-color-muted"],
+ );
});
});
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
index ba8504b..ea1568a 100644
--- a/packages/ui/src/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -79,7 +79,9 @@ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
getThemeColor(reference, resolvedTheme),
]),
),
- light: {},
+ light: {
+ "--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
+ },
dark: {},
});
diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx
index 7631a9f..8f66930 100644
--- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx
+++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
-import { render, screen, waitFor } from "@testing-library/react";
-import type { FC } from "react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
}
describe("RouteErrorBoundary", () => {
+ it.each(["/accounts", "/login"])(
+ "recovers a transient failure through the fallback on %s itself",
+ async (path) => {
+ authenticated = path === "/accounts";
+ let failing = true;
+ const RecoverableRoute: FC = () => {
+ if (failing) throw new Error("temporary failure");
+ return recovered route
;
+ };
+ render(
+
+
+
+
+
+
+ ,
+ );
+ const action = screen.getByRole("link", { name: "Продолжить" });
+ failing = false;
+ fireEvent.click(action);
+ expect(await screen.findByText("recovered route")).toBeInTheDocument();
+ },
+ );
+
+ it("keeps a persistent failure recoverable without an automatic reset loop", () => {
+ authenticated = true;
+ renderBoundary("/accounts");
+ fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
+ expect(
+ screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Перезагрузить страницу" }),
+ ).toHaveAttribute("href", "/accounts");
+ });
+
+ it("offers document navigation when React.lazy caches a rejected chunk", async () => {
+ authenticated = false;
+ const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
+ const Chunk = lazy(load);
+ render(
+
+
+
+ loading chunk}>
+
+
+
+
+ ,
+ );
+ fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
+ expect(load).toHaveBeenCalledTimes(1);
+ const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
+ expect(reload).toHaveAttribute("href", "/login");
+ // Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
+ let intercepted = true;
+ const preventNavigation = (event: MouseEvent) => {
+ intercepted = event.defaultPrevented;
+ event.preventDefault();
+ };
+ document.addEventListener("click", preventNavigation, { once: true });
+ fireEvent.click(reload);
+ expect(intercepted).toBe(false);
+ });
+
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx
index 450410a..a6a09ab 100644
--- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx
+++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC = (props) => {
+
);
};
const RouteErrorBoundary: FC = (props) => {
const { children } = props;
- const { pathname, search } = useLocation();
+ const { pathname, search, key } = useLocation();
const { user } = useAuth();
return (
-
+
{children}
);
diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts
index 2e223e5..93c1864 100644
--- a/src/entities/bank-card/model/form-validation.ts
+++ b/src/entities/bank-card/model/form-validation.ts
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMonth(value: string | null) {
- return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
+ return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
diff --git a/src/entities/bank-card/model/period.test.ts b/src/entities/bank-card/model/period.test.ts
new file mode 100644
index 0000000..01a72e8
--- /dev/null
+++ b/src/entities/bank-card/model/period.test.ts
@@ -0,0 +1,25 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { bankPeriod } from "./period";
+
+afterEach(() => vi.useRealTimers());
+
+it.each([
+ ["0001-01", "0001-01-01", "0001-01-31"],
+ ["0099-12", "0099-12-01", "0099-12-31"],
+ ["2026-09", "2026-09-01", "2026-09-30"],
+ ["2024-02", "2024-02-01", "2024-02-29"],
+ ["0004-02", "0004-02-01", "0004-02-29"],
+])("keeps the ISO year for legacy month %s", (month, from, to) => {
+ expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
+});
+
+it("rejects zero year consistently in legacy and explicit periods", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
+ for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
+ expect(bankPeriod(new URLSearchParams(query))).toEqual({
+ from: "2026-09-01",
+ to: "2026-09-30",
+ });
+ }
+});
diff --git a/src/entities/bank-card/model/period.ts b/src/entities/bank-card/model/period.ts
index 88ff129..d6af745 100644
--- a/src/entities/bank-card/model/period.ts
+++ b/src/entities/bank-card/model/period.ts
@@ -1,10 +1,12 @@
-import dayjs from "dayjs";
import { z } from "zod";
import { bankMonth } from "./form-validation";
const calendarDate = z.string().date();
+const ISO_DATE_LENGTH = 10;
function bankPeriod(params: URLSearchParams) {
const month = bankMonth(params.get("month"));
+ const monthEnd = new Date(`${month}-01T00:00:00Z`);
+ monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = params.get("from");
const to = params.get("to");
return from &&
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
? { from, to }
: {
from: `${month}-01`,
- to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"),
+ to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH),
};
}
diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx
index 4179ef3..d2fc5f8 100644
--- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx
+++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
+ it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
+ configureAuthClient({
+ accessToken: () => "test",
+ refresh: async () => false,
+ clear: () => {},
+ });
+ const categories = [
+ { id: "a", name: "Первая", isDefault: false, version: 1 },
+ { id: "b", name: "Вторая", isDefault: false, version: 4 },
+ ];
+ let reads = 0;
+ let release = () => {};
+ const reloaded = new Promise((resolve) => {
+ release = resolve;
+ });
+ const patches: { id: unknown; body: unknown }[] = [];
+ server.use(
+ http.get("*/api/v1/bank-cards/categories", async () => {
+ reads++;
+ if (reads === 3) await reloaded;
+ return HttpResponse.json(
+ categories.map((category) =>
+ category.id === "a" && reads > 1
+ ? {
+ ...category,
+ name: reads >= 3 ? "Первая обновлена" : category.name,
+ version: 2,
+ }
+ : category,
+ ),
+ );
+ }),
+ http.patch(
+ "*/api/v1/bank-cards/categories/:id",
+ async ({ request, params }) => {
+ patches.push({ id: params.id, body: await request.json() });
+ return patches.length === 1
+ ? HttpResponse.json(
+ { code: "BANK_VERSION_CONFLICT" },
+ { status: 409 },
+ )
+ : HttpResponse.json(categories[1]);
+ },
+ ),
+ );
+ render(
+
+
+ {}} />
+
+ ,
+ );
+ fireEvent.click(
+ (await screen.findAllByRole("button", { name: "Переименовать" }))[0],
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Перечитать запись" }),
+ );
+ await waitFor(() => expect(reads).toBe(3));
+ fireEvent.click(
+ screen.getAllByRole("button", { name: "Переименовать" })[1],
+ );
+ fireEvent.change(screen.getByLabelText(/Название категории/), {
+ target: { value: "Новая вторая" },
+ });
+ await act(async () => {
+ release();
+ await reloaded;
+ });
+ await screen.findByText("Первая обновлена");
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("button", {
+ name: /Использовать актуальную версию/,
+ }),
+ ).not.toBeInTheDocument(),
+ );
+ expect(screen.getByLabelText(/Название категории/)).toHaveValue(
+ "Новая вторая",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ await waitFor(() =>
+ expect(patches).toEqual([
+ { id: "a", body: { name: "Первая", version: 1 } },
+ { id: "b", body: { name: "Новая вторая", version: 4 } },
+ ]),
+ );
+ });
+
+ it.each(["Отмена", "Сохранить"])(
+ "clears reloaded recovery after %s",
+ async (action) => {
+ configureAuthClient({
+ accessToken: () => "test",
+ refresh: async () => false,
+ clear: () => {},
+ });
+ let version = 1;
+ let writes = 0;
+ server.use(
+ http.get("*/api/v1/bank-cards/categories", () =>
+ HttpResponse.json([
+ { id: "a", name: "Первая", isDefault: false, version },
+ ]),
+ ),
+ http.patch("*/api/v1/bank-cards/categories/a", () => {
+ version = 2;
+ return ++writes === 1
+ ? HttpResponse.json(
+ { code: "BANK_VERSION_CONFLICT" },
+ { status: 409 },
+ )
+ : HttpResponse.json({});
+ }),
+ );
+ render(
+
+
+ {}} />
+
+ ,
+ );
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Переименовать" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Перечитать запись" }),
+ );
+ await screen.findByRole("button", {
+ name: /Использовать актуальную версию/,
+ });
+ fireEvent.click(screen.getByRole("button", { name: action }));
+ await screen.findByRole("button", { name: "Добавить категорию" });
+ expect(
+ screen.queryByRole("button", {
+ name: /Использовать актуальную версию/,
+ }),
+ ).not.toBeInTheDocument();
+ },
+ );
+
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
index fcd3f9b..6c8dad9 100644
--- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
+++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
@@ -8,7 +8,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
-import { type FC, useState } from "react";
+import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
@@ -60,6 +60,7 @@ const CategoryManager: FC = (props) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState();
+ const editSession = useRef(0);
const [error, setError] = useState();
const [latest, setLatest] = useState();
const methods = useForm({
@@ -114,7 +115,9 @@ const CategoryManager: FC = (props) => {
});
if (result) props.onCreated?.(result.id);
}
+ editSession.current++;
setEditing(undefined);
+ setLatest(undefined);
methods.reset({ name: "" });
} catch (failure) {
setError(failure);
@@ -138,6 +141,7 @@ const CategoryManager: FC = (props) => {