diff --git a/index.html b/index.html
index 0f1cb5b..95fcbea 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
-
wealth-ledger-frontend
+ WealthLedger
diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx
index 81d6a53..3760312 100644
--- a/src/app/routes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes.test.tsx
@@ -46,6 +46,7 @@ describe("AppRoutes", () => {
screen.getByRole("heading", { level: 1, name: title }),
).toBeInTheDocument(),
);
+ expect(document.title).toBe(`${title} — WealthLedger`);
});
it("redirects anonymous users from accounts", async () => {
@@ -86,6 +87,7 @@ describe("AppRoutes", () => {
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument();
+ expect(document.title).toBe("Счета — WealthLedger");
expect(credentials).toEqual({
email: "user@example.com",
password: "a secure password",
diff --git a/src/app/routes/DocumentTitle.test.tsx b/src/app/routes/DocumentTitle.test.tsx
new file mode 100644
index 0000000..8d5579e
--- /dev/null
+++ b/src/app/routes/DocumentTitle.test.tsx
@@ -0,0 +1,111 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import type { FC } from "react";
+import { MemoryRouter, useNavigate } from "react-router";
+import { beforeEach, describe, expect, it } from "vitest";
+import { AuthProvider } from "@/features/auth";
+import { createTestQueryClient } from "@/test/query";
+import { server } from "@/test/server";
+import { ProtectedDocumentTitle } from "./DocumentTitle";
+
+const Navigation: FC = () => {
+ const navigate = useNavigate();
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const renderTitle = (path: string) =>
+ render(
+
+
+
+
+
+
+
+ ,
+ );
+
+beforeEach(() => {
+ document.title = "Previous page";
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: "test-token",
+ user: { id: "user-1", email: "user@example.com" },
+ }),
+ ),
+ );
+});
+
+describe("document titles", () => {
+ it("updates a loading account name and follows account scope and history", async () => {
+ let resolveAccounts!: () => void;
+ const accountsReady = new Promise((resolve) => {
+ resolveAccounts = resolve;
+ });
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", async () => {
+ await accountsReady;
+ return HttpResponse.json([{ id: "a", name: "ИИС" }]);
+ }),
+ );
+ renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
+ expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
+ await act(async () => resolveAccounts());
+ await waitFor(() =>
+ expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Все счета" }));
+ expect(document.title).toBe("Пассивный доход — Все счета — WealthLedger");
+ fireEvent.click(screen.getByRole("button", { name: "Назад" }));
+ expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger");
+ });
+
+ it.each([
+ ["bonds", "SU26238RMFS4", "Облигация", "ОФЗ 26238"],
+ ["stocks", "SBER", "Акция", "Сбербанк"],
+ ])(
+ "updates the %s title when instrument data arrives",
+ async (kind, id, fallback, name) => {
+ server.use(
+ http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
+ HttpResponse.json({ name }),
+ ),
+ );
+ renderTitle(`/${kind}/${id}`);
+ expect(document.title).toBe(`${fallback} — WealthLedger`);
+ await waitFor(() =>
+ expect(document.title).toBe(`${name} — WealthLedger`),
+ );
+ },
+ );
+
+ it("keeps a useful title when the instrument is unavailable", async () => {
+ let requested = false;
+ server.use(
+ http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
+ requested = true;
+ return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
+ }),
+ );
+ renderTitle("/stocks/missing");
+ await waitFor(() => expect(requested).toBe(true));
+ expect(document.title).toBe("Акция — WealthLedger");
+ });
+});
diff --git a/src/app/routes/DocumentTitle.tsx b/src/app/routes/DocumentTitle.tsx
new file mode 100644
index 0000000..a2ab3ed
--- /dev/null
+++ b/src/app/routes/DocumentTitle.tsx
@@ -0,0 +1,88 @@
+import { useDocumentTitle } from "@mantine/hooks";
+import type { FC } from "react";
+import { useTranslation } from "react-i18next";
+import { useMatch } from "react-router";
+import { useBond } from "@/entities/bond";
+import { useBrokerageAccounts } from "@/entities/brokerage-account";
+import { useStock } from "@/entities/stock";
+
+type DocumentTitleProps = { title: string };
+
+const DocumentTitle: FC = (props) => {
+ const { t } = useTranslation();
+ useDocumentTitle(`${props.title} — ${t("appName")}`);
+ return null;
+};
+
+type AccountDocumentTitleProps = {
+ accountId: string;
+ section?: string;
+};
+
+const AccountDocumentTitle: FC = (props) => {
+ const { t } = useTranslation();
+ const { data } = useBrokerageAccounts();
+ const accountName =
+ props.accountId === "all"
+ ? t("accounts.all")
+ : (data?.find((account) => account.id === props.accountId)?.name ??
+ t("account.title"));
+ const section =
+ props.section === "income"
+ ? t("income.title")
+ : props.section === "operations"
+ ? t("ledger.title")
+ : props.section === "events"
+ ? t("events.title")
+ : t("portfolio.title");
+ return ;
+};
+
+type InstrumentDocumentTitleProps = { identifier: string };
+
+const BondDocumentTitle: FC = (props) => {
+ const { t } = useTranslation();
+ const { data } = useBond(props.identifier);
+ return ;
+};
+
+const StockDocumentTitle: FC = (props) => {
+ const { t } = useTranslation();
+ const { data } = useStock(props.identifier);
+ return ;
+};
+
+const ProtectedDocumentTitle: FC = () => {
+ const { t } = useTranslation();
+ const account = useMatch("/accounts/:accountId/:section?");
+ const bond = useMatch("/bonds/:bondId");
+ const stock = useMatch("/stocks/:stockId");
+ const bonds = useMatch("/bonds");
+ const stocks = useMatch("/stocks");
+ const settings = useMatch("/settings");
+ if (account)
+ return (
+
+ );
+ if (bond) return ;
+ if (stock)
+ return ;
+ return (
+
+ );
+};
+
+export { ProtectedDocumentTitle };
diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx
index 310e2ff..895b5b7 100644
--- a/src/app/routes/ProtectedLayout.tsx
+++ b/src/app/routes/ProtectedLayout.tsx
@@ -1,9 +1,15 @@
import type { FC } from "react";
import { AppBreadcrumbs } from "@/app/ui";
import { AppShell } from "@/shared/ui";
+import { ProtectedDocumentTitle } from "./DocumentTitle";
const ProtectedLayout: FC = () => {
- return } />;
+ return (
+ <>
+
+ } />
+ >
+ );
};
export { ProtectedLayout };
diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary.test.tsx
index 0acd840..7631a9f 100644
--- a/src/app/routes/RouteErrorBoundary.test.tsx
+++ b/src/app/routes/RouteErrorBoundary.test.tsx
@@ -55,6 +55,7 @@ describe("RouteErrorBoundary", () => {
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
+ expect(document.title).toBe("Не удалось открыть страницу — WealthLedger");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
"href",
"/login",
diff --git a/src/app/routes/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary.tsx
index 3035a80..450410a 100644
--- a/src/app/routes/RouteErrorBoundary.tsx
+++ b/src/app/routes/RouteErrorBoundary.tsx
@@ -1,4 +1,5 @@
import { Button, Stack, Text, Title } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
import type { FC, PropsWithChildren } from "react";
import { Component } from "react";
import { useTranslation } from "react-i18next";
@@ -40,6 +41,7 @@ class Boundary extends Component {
const RouteErrorFallback: FC = (props) => {
const { authenticated } = props;
const { t } = useTranslation();
+ useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
const to = authenticated ? "/accounts" : "/login";
return (
diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx
index adb7753..fc30c22 100644
--- a/src/pages/login/ui/LoginPage.tsx
+++ b/src/pages/login/ui/LoginPage.tsx
@@ -1,9 +1,11 @@
+import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth";
const LoginPage: FC = () => {
const { t } = useTranslation();
+ useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
return (
diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx
index c6cafb8..21d8cb8 100644
--- a/src/pages/not-found/ui/NotFoundPage.tsx
+++ b/src/pages/not-found/ui/NotFoundPage.tsx
@@ -1,4 +1,5 @@
import { Button, Stack, Title } from "@mantine/core";
+import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -6,6 +7,7 @@ import { StateView } from "@/shared/ui";
const NotFoundPage: FC = () => {
const { t } = useTranslation();
+ useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
return (
diff --git a/src/pages/register/ui/RegisterPage.tsx b/src/pages/register/ui/RegisterPage.tsx
index fb11abc..2df460f 100644
--- a/src/pages/register/ui/RegisterPage.tsx
+++ b/src/pages/register/ui/RegisterPage.tsx
@@ -1,9 +1,11 @@
+import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth";
const RegisterPage: FC = () => {
const { t } = useTranslation();
+ useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
return (