Compare commits
3
Commits
v1.3.0
...
aaee9591d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaee9591d7 | ||
|
|
9619dc9791 | ||
|
|
71fbe7774d |
@@ -1,4 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { ComponentType, FC } from "react";
|
||||||
|
|
||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
|
|||||||
import { LazyPage } from "../RouteFallback";
|
import { LazyPage } from "../RouteFallback";
|
||||||
import { Protected, Public } from "../RouteGuards";
|
import { Protected, Public } from "../RouteGuards";
|
||||||
|
|
||||||
const CardPage = lazy(async () => {
|
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
|
||||||
const { CardPage } = await import("@/pages/card");
|
function lazyPage<Name extends string>(
|
||||||
|
load: () => Promise<Record<Name, ComponentType>>,
|
||||||
|
name: Name,
|
||||||
|
) {
|
||||||
|
return lazy(async () => {
|
||||||
|
const page = await load();
|
||||||
|
|
||||||
return { default: CardPage };
|
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
|
||||||
});
|
return { default: page[name] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const CardsPage = lazy(async () => {
|
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
|
||||||
const { CardsPage } = await import("@/pages/cards");
|
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
|
||||||
|
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
|
||||||
return { default: CardsPage };
|
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
|
||||||
});
|
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
|
||||||
|
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
|
||||||
const BondPage = lazy(async () => {
|
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
|
||||||
const { BondPage } = await import("@/pages/bond");
|
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
|
||||||
|
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
|
||||||
return { default: BondPage };
|
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
|
||||||
});
|
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
|
||||||
|
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
|
||||||
const BondsPage = lazy(async () => {
|
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
|
||||||
const { BondsPage } = await import("@/pages/bonds");
|
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
|
||||||
|
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
|
||||||
return { default: BondsPage };
|
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
|
||||||
});
|
|
||||||
|
|
||||||
const StockPage = lazy(async () => {
|
|
||||||
const { StockPage } = await import("@/pages/stock");
|
|
||||||
|
|
||||||
return { default: StockPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const StocksPage = lazy(async () => {
|
|
||||||
const { StocksPage } = await import("@/pages/stocks");
|
|
||||||
|
|
||||||
return { default: StocksPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountPage = lazy(async () => {
|
|
||||||
const { AccountPage } = await import("@/pages/account");
|
|
||||||
|
|
||||||
return { default: AccountPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountEventsPage = lazy(async () => {
|
|
||||||
const { AccountEventsPage } = await import("@/pages/account-events");
|
|
||||||
|
|
||||||
return { default: AccountEventsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountOperationsPage = lazy(async () => {
|
|
||||||
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
|
||||||
|
|
||||||
return { default: AccountOperationsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountIncomePage = lazy(async () => {
|
|
||||||
const { AccountIncomePage } = await import("@/pages/account-income");
|
|
||||||
|
|
||||||
return { default: AccountIncomePage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AccountsPage = lazy(async () => {
|
|
||||||
const { AccountsPage } = await import("@/pages/accounts");
|
|
||||||
|
|
||||||
return { default: AccountsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const LoginPage = lazy(async () => {
|
|
||||||
const { LoginPage } = await import("@/pages/login");
|
|
||||||
|
|
||||||
return { default: LoginPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const NotFoundPage = lazy(async () => {
|
|
||||||
const { NotFoundPage } = await import("@/pages/not-found");
|
|
||||||
|
|
||||||
return { default: NotFoundPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const RegisterPage = lazy(async () => {
|
|
||||||
const { RegisterPage } = await import("@/pages/register");
|
|
||||||
|
|
||||||
return { default: RegisterPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const SettingsPage = lazy(async () => {
|
|
||||||
const { SettingsPage } = await import("@/pages/settings");
|
|
||||||
|
|
||||||
return { default: SettingsPage };
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProtectedLayout = lazy(async () => {
|
|
||||||
const { ProtectedLayout } = await import("../ProtectedLayout");
|
|
||||||
|
|
||||||
return { default: ProtectedLayout };
|
|
||||||
});
|
|
||||||
|
|
||||||
const AppRoutes: FC = () => (
|
const AppRoutes: FC = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import type { IncomeAnalytics } from "@/entities/income";
|
import type { IncomeAnalytics } from "@/entities/income";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -403,6 +404,35 @@ describe("AccountIncomePage", () => {
|
|||||||
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["full", analytics],
|
||||||
|
["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
|
||||||
|
])("shows the server source time and last attempt for a %s result", async (_name, data) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
resource(data, {
|
||||||
|
freshness: "stale",
|
||||||
|
lastAttempt: {
|
||||||
|
finishedAt: "2026-08-27T01:00:00Z",
|
||||||
|
problems: [],
|
||||||
|
startedAt: "2026-08-27T00:59:00Z",
|
||||||
|
state: "failed",
|
||||||
|
},
|
||||||
|
lastSuccessfulAt: "2026-08-26T12:00:00Z",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
|
||||||
|
formatSnapshotTime("2026-08-26T12:00:00Z"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a quiet realized error", async () => {
|
it("shows a quiet realized error", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(
|
http.get(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useParams, useSearchParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
|
import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib";
|
||||||
@@ -30,6 +32,7 @@ type AccountIncomePageViewModel
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSubmit: (values: IncomeForm) => void;
|
onSubmit: (values: IncomeForm) => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
totalVisible: boolean;
|
totalVisible: boolean;
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|||||||
onReset,
|
onReset,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
partial: analytics.meta?.completeness === "incomplete",
|
partial: analytics.meta?.completeness === "incomplete",
|
||||||
|
source: analytics.meta,
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
totalVisible: analytics.meta?.completeness === "complete",
|
totalVisible: analytics.meta?.completeness === "complete",
|
||||||
};
|
};
|
||||||
|
|||||||
+8
@@ -9,6 +9,8 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
@@ -49,6 +51,12 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|
||||||
|
<SourceSyncStatus
|
||||||
|
lastAttempt={props.source?.lastAttempt}
|
||||||
|
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||||
|
stale={props.source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
<AccountIncomePageLoadedSummary {...props} />
|
<AccountIncomePageLoadedSummary {...props} />
|
||||||
|
|
||||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
|
|||||||
|
|
||||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -231,6 +232,37 @@ describe("AccountPage states", () => {
|
|||||||
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows server source time and attempt for an empty account", async () => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(resource(
|
||||||
|
{
|
||||||
|
...portfolio,
|
||||||
|
cashBalances: [],
|
||||||
|
isStale: false,
|
||||||
|
positions: [],
|
||||||
|
totalsByCurrency: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lastAttempt: {
|
||||||
|
finishedAt: "2026-08-14T01:00:00Z",
|
||||||
|
problems: [],
|
||||||
|
startedAt: "2026-08-14T00:59:00Z",
|
||||||
|
state: "failed",
|
||||||
|
},
|
||||||
|
lastSuccessfulAt: "2026-08-13T12:00:00Z",
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
renderAccountRoute(createTestQueryClient());
|
||||||
|
expect(await screen.findByText(/^Данные на /)).toHaveTextContent(
|
||||||
|
formatSnapshotTime("2026-08-13T12:00:00Z"),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Обновление не удалось")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("expands and resets other when switching slice", () => {
|
it("expands and resets other when switching slice", () => {
|
||||||
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
const positions = [400, 250, 150, 100, 50, 30, 20].map(
|
||||||
(value, index) => position({
|
(value, index) => position({
|
||||||
@@ -874,7 +906,6 @@ describe("AccountPage states", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/T-Bank временно недоступен/))
|
expect(screen.queryByText(/T-Bank временно недоступен/))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("radio", { name: "Облигации" }),
|
screen.getByRole("radio", { name: "Облигации" }),
|
||||||
@@ -1276,7 +1307,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(structure).toHaveTextContent("10 ₽");
|
expect(structure).toHaveTextContent("10 ₽");
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
});
|
});
|
||||||
@@ -1320,7 +1351,7 @@ describe("AccountPage states", () => {
|
|||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["-0,5 $"]),
|
).toEqual(["-0,5 $"]),
|
||||||
);
|
);
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
|
||||||
expect(structure).not.toHaveTextContent("10 ₽");
|
expect(structure).not.toHaveTextContent("10 ₽");
|
||||||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||||
expect(
|
expect(
|
||||||
@@ -1361,7 +1392,7 @@ describe("AccountPage states", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("definition").map(node => node.textContent),
|
screen.getAllByRole("definition").map(node => node.textContent),
|
||||||
).toEqual(["1 234,5 ₽"]);
|
).toEqual(["1 234,5 ₽"]);
|
||||||
expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument();
|
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("status", { name: "Загрузка" }),
|
screen.queryByRole("status", { name: "Загрузка" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const AccountPage: FC = () => {
|
|||||||
incomeSummary={viewModel.incomeSummary}
|
incomeSummary={viewModel.incomeSummary}
|
||||||
recentEvents={viewModel.recentEvents}
|
recentEvents={viewModel.recentEvents}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={viewModel.recentOperations}
|
||||||
|
source={viewModel.source}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { useIncomeSummary } from "@/entities/income";
|
import { useIncomeSummary } from "@/entities/income";
|
||||||
import { usePortfolio } from "@/entities/portfolio";
|
import { usePortfolio } from "@/entities/portfolio";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { apiErrorCode, isBrokerageAccessLoss } from "@/shared/api";
|
||||||
import { useInitialAvailability } from "@/shared/lib";
|
import { useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ type AccountPageContent = {
|
|||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||||
@@ -62,7 +63,6 @@ type AccountPageLoadedViewModel = AccountPageContent & {
|
|||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
tab: string;
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||||
@@ -74,12 +74,6 @@ type AccountPageViewModel
|
|||||||
| { code?: string; state: "error" }
|
| { code?: string; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function isPortfolioAccessFailure(code?: string) {
|
|
||||||
return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
|
|
||||||
|| isApiErrorCode(code, ApiErrorCode.NotFound)
|
|
||||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -119,7 +113,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const code = apiErrorCode(portfolio.error);
|
const code = apiErrorCode(portfolio.error);
|
||||||
const isAccessFailure = isPortfolioAccessFailure(code);
|
const isAccessFailure = isBrokerageAccessLoss(code);
|
||||||
|
|
||||||
const availability = useInitialAvailability({
|
const availability = useInitialAvailability({
|
||||||
hasContent: !!portfolio.data,
|
hasContent: !!portfolio.data,
|
||||||
@@ -152,6 +146,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
incomeSummary,
|
incomeSummary,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
|
source: portfolio.meta,
|
||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
||||||
|
|
||||||
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +27,7 @@ type AccountPageEmptyProps = {
|
|||||||
incomeSummary?: AccountPageIncomeSummary;
|
incomeSummary?: AccountPageIncomeSummary;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
source?: InvestmentResourceMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||||
@@ -40,6 +43,12 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<AccountHeading account={props.account} accountId={props.accountId} />
|
<AccountHeading account={props.account} accountId={props.accountId} />
|
||||||
|
|
||||||
|
<SourceSyncStatus
|
||||||
|
lastAttempt={props.source?.lastAttempt}
|
||||||
|
lastSuccessfulAt={props.source?.lastSuccessfulAt}
|
||||||
|
stale={props.source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
<StateView text={text} title={t("portfolio.emptyTitle")} />
|
||||||
|
|
||||||
<PortfolioStructure
|
<PortfolioStructure
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import createQueryClient from "openapi-react-query";
|
|||||||
|
|
||||||
import type { paths } from "./generated/schema";
|
import type { paths } from "./generated/schema";
|
||||||
|
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
import {
|
||||||
|
ApiErrorCode,
|
||||||
|
apiErrorCode,
|
||||||
|
isApiErrorCode,
|
||||||
|
isBrokerageAccessLoss,
|
||||||
|
} from "./problem-details";
|
||||||
|
|
||||||
const HTTP_UNAUTHORIZED = 401;
|
const HTTP_UNAUTHORIZED = 401;
|
||||||
const HTTP_REQUEST_TIMEOUT = 408;
|
const HTTP_REQUEST_TIMEOUT = 408;
|
||||||
@@ -165,12 +170,8 @@ function isNetworkFailure(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPermanentCode(code?: string): boolean {
|
function isPermanentCode(code?: string): boolean {
|
||||||
return [
|
return isBrokerageAccessLoss(code)
|
||||||
ApiErrorCode.InvalidTbankToken,
|
|| isApiErrorCode(code, ApiErrorCode.Unauthorized);
|
||||||
ApiErrorCode.NotFound,
|
|
||||||
ApiErrorCode.TbankNotConnected,
|
|
||||||
ApiErrorCode.Unauthorized,
|
|
||||||
].some(permanent => isApiErrorCode(code, permanent));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransientReadFailure(
|
function isTransientReadFailure(
|
||||||
|
|||||||
Vendored
+8
@@ -1232,6 +1232,12 @@ export interface components {
|
|||||||
data: components["schemas"]["IncomeAnalyticsDto"] | null;
|
data: components["schemas"]["IncomeAnalyticsDto"] | null;
|
||||||
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
|
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
|
||||||
};
|
};
|
||||||
|
UpcomingEventsWindowDto: {
|
||||||
|
/** Format: date */
|
||||||
|
from: string;
|
||||||
|
/** Format: date */
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
UpcomingEventDto: {
|
UpcomingEventDto: {
|
||||||
account?: components["schemas"]["AccountReferenceDto"];
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
@@ -1252,6 +1258,8 @@ export interface components {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
calculatedAt: string;
|
calculatedAt: string;
|
||||||
|
/** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */
|
||||||
|
window: components["schemas"]["UpcomingEventsWindowDto"];
|
||||||
events: components["schemas"]["UpcomingEventDto"][];
|
events: components["schemas"]["UpcomingEventDto"][];
|
||||||
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
|
incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export {
|
|||||||
configureAuthClient,
|
configureAuthClient,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
export type { paths } from "./generated/schema";
|
export type { paths } from "./generated/schema";
|
||||||
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
export {
|
||||||
|
ApiErrorCode,
|
||||||
|
apiErrorCode,
|
||||||
|
isApiErrorCode,
|
||||||
|
isBrokerageAccessLoss,
|
||||||
|
} from "./problem-details";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type InvestmentResourceMeta,
|
type InvestmentResourceMeta,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
import {
|
||||||
|
ApiErrorCode,
|
||||||
|
apiErrorCode,
|
||||||
|
isApiErrorCode,
|
||||||
|
isBrokerageAccessLoss,
|
||||||
|
} from "./problem-details";
|
||||||
|
|
||||||
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
|
it("matches known codes and preserves unknown wire codes for fallback handling", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -16,3 +21,10 @@ it("matches known codes and preserves unknown wire codes for fallback handling",
|
|||||||
expect(apiErrorCode(null)).toBeUndefined();
|
expect(apiErrorCode(null)).toBeUndefined();
|
||||||
expect(apiErrorCode({})).toBeUndefined();
|
expect(apiErrorCode({})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("separates brokerage access loss from UNAUTHORIZED, temporary and unknown codes", () => {
|
||||||
|
for (const code of ["INVALID_TBANK_TOKEN", "NOT_FOUND", "TBANK_NOT_CONNECTED"])
|
||||||
|
expect(isBrokerageAccessLoss(code)).toBe(true);
|
||||||
|
for (const code of ["UNAUTHORIZED", "TBANK_UNAVAILABLE", "FUTURE_PROVIDER_ERROR", undefined])
|
||||||
|
expect(isBrokerageAccessLoss(code)).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,4 +41,14 @@ function isApiErrorCode(
|
|||||||
return value === expected;
|
return value === expected;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ApiErrorCode, apiErrorCode, isApiErrorCode };
|
// Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session
|
||||||
|
// failure with its own refresh/sign-out path, so it is deliberately excluded.
|
||||||
|
function isBrokerageAccessLoss(code?: string): boolean {
|
||||||
|
return [
|
||||||
|
ApiErrorCode.InvalidTbankToken,
|
||||||
|
ApiErrorCode.NotFound,
|
||||||
|
ApiErrorCode.TbankNotConnected,
|
||||||
|
].some(loss => isApiErrorCode(code, loss));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss };
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { components, paths } from "./generated/schema";
|
|||||||
|
|
||||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
|
||||||
|
|
||||||
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
||||||
|
|
||||||
@@ -39,15 +39,7 @@ function invalidateResourceAccess(
|
|||||||
group: string,
|
group: string,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
) {
|
) {
|
||||||
const errorCode = apiErrorCode(error);
|
if (!isBrokerageAccessLoss(apiErrorCode(error))) return;
|
||||||
if (
|
|
||||||
[
|
|
||||||
ApiErrorCode.InvalidTbankToken,
|
|
||||||
ApiErrorCode.NotFound,
|
|
||||||
ApiErrorCode.TbankNotConnected,
|
|
||||||
].every(code => !isApiErrorCode(errorCode, code))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const affected = {
|
const affected = {
|
||||||
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
predicate: (cached: { meta?: Record<string, unknown> }) =>
|
||||||
|
|||||||
@@ -7243,6 +7243,23 @@
|
|||||||
"meta"
|
"meta"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UpcomingEventsWindowDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"from",
|
||||||
|
"to"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpcomingEventDto": {
|
"UpcomingEventDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7324,6 +7341,14 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
|
"window": {
|
||||||
|
"description": "Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/UpcomingEventsWindowDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -7340,6 +7365,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"accountId",
|
"accountId",
|
||||||
"calculatedAt",
|
"calculatedAt",
|
||||||
|
"window",
|
||||||
"events",
|
"events",
|
||||||
"incomeTotalsByCurrency"
|
"incomeTotalsByCurrency"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user