From 3c226f8c29efceccb75af0d6424639a757534f39 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 9 Oct 2026 19:56:20 +0300 Subject: [PATCH] feat: render hourly investment states --- .../api/useBrokerageAccounts.ts | 3 - .../BrokerageAccountList.stories.tsx | 24 +- .../BrokerageAccountList.tsx | 5 +- .../instrument-details/model/section.test.ts | 31 +- .../instrument-details/model/section.ts | 4 +- .../model/screen-sync.test.tsx | 7 +- .../tbank-connection/model/screen-sync.ts | 13 +- .../AccountEventsPage.filters.test.tsx | 16 +- .../AccountEventsPage.test.tsx | 20 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 10 +- .../AccountEventsPageLoaded.view.tsx | 7 + .../account-income/model/display.test.ts | 11 +- src/pages/account-income/model/display.ts | 2 +- .../AccountIncomePage.test.tsx | 16 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 4 +- .../AccountOperationsPage.test.tsx | 9 +- .../AccountOperationsPage.vm.ts | 10 +- .../AccountOperationsPageLoaded.view.tsx | 7 + src/pages/account/model/previews.ts | 23 +- .../ui/AccountPage/AccountPage.vm.test.tsx | 6 +- .../account/ui/AccountPage/AccountPage.vm.ts | 9 +- .../components/AccountPageLoaded.view.tsx | 9 + .../ui/AccountsPage/AccountsPage.test.tsx | 15 +- .../accounts/ui/AccountsPage/AccountsPage.tsx | 2 +- .../ui/AccountsPage/AccountsPage.vm.ts | 18 +- .../components/AccountsPageLoaded.view.tsx | 50 +- src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 6 +- .../stocks/ui/StocksPage/StocksPage.vm.ts | 6 +- src/shared/api/generated/schema.d.ts | 771 ++++----- src/shared/api/resource-query.test.tsx | 13 +- src/shared/api/resource-query.ts | 2 +- src/shared/api/spec/openapi.json | 1450 +++++++++-------- src/shared/i18n/locales/ru.ts | 7 + .../SourceSyncStatus.test.tsx | 15 + .../ui/SourceSyncStatus/SourceSyncStatus.tsx | 15 + src/test/resource.ts | 10 +- 36 files changed, 1388 insertions(+), 1238 deletions(-) diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 2b8cb87..d54ea76 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -10,8 +10,6 @@ type BrokerageAccount = NonNullable< paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] >[number]; -type BrokerageAccountsResponse = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]; - function useBrokerageAccounts() { const query = useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -21,7 +19,6 @@ function useBrokerageAccounts() { return { ...query, - sync: query.sync as BrokerageAccountsResponse["sync"] | null, }; } diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 3e7dc7e..255e46c 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -205,24 +205,32 @@ export const IndependentFreshness: Story ], [ { - availability: "complete", + availability: "available", + completeness: "complete", dataRevision: "1", freshness: "stale", - lastSuccessfulAt: "2026-09-24T10:30:00Z", - resourceKey: "portfolio/storybook-account", - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: "failed", + lastAttempt: { + finishedAt: "2026-09-24T10:31:00Z", + problems: [], + startedAt: "2026-09-24T10:30:00Z", state: "failed", }, + lastSuccessfulAt: "2026-09-24T10:30:00Z", + resourceKey: "portfolio/storybook-account", }, { - availability: "missing", + availability: "initial-loading", + completeness: "incomplete", dataRevision: "0", freshness: "unknown", + lastAttempt: { + finishedAt: null, + problems: [], + startedAt: "2026-09-24T10:30:00Z", + state: "running", + }, lastSuccessfulAt: null, resourceKey: "portfolio/waiting", - sync: { errorCode: null, id: "waiting", state: "running" }, }, ], ), diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index a706ea1..dd8dd3b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC = ( const { source } = props; const { t } = useTranslation(); - const isSyncing = source?.sync.state === "running"; + const isSyncing = source?.lastAttempt.state === "running"; const errorText - = source?.sync.state === "failed" ? t("accounts.refreshError") : null; + = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null; return ( <> diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index 40b5527..c4642c2 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -5,20 +5,23 @@ import { resource } from "@/test/resource"; import { section } from "./section"; describe("instrument source and transport presentation", () => { - it.each(["missing", "partial", "complete"] as const)( + it.each([ + ["initial-loading", "incomplete"], + ["available", "complete"], + ] as const)( "keeps %s separate from successful transport", - (availability) => { - const data = availability === "missing" ? undefined : { rows: [] }; + (availability, completeness) => { + const data = availability === "initial-loading" ? undefined : { rows: [] }; const state = section({ data, isError: false, - meta: resource(data, { availability }).meta, + meta: resource(data, { availability, completeness }).meta, }); - expect(state.loading).toBe(availability === "missing"); - expect(state.partial).toBe(availability === "partial"); - expect(state.hasData).toBe(availability !== "missing"); + expect(state.loading).toBe(availability === "initial-loading"); + expect(state.partial).toBe(completeness === "incomplete"); + expect(state.hasData).toBe(availability !== "initial-loading"); }, ); it("preserves empty successful data instead of treating it as missing", () => { @@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => { it("preserves a full source when force fails", () => { const meta = resource([], { - sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, }).meta; const state = section({ data: [], isError: false, meta }); @@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => { data: undefined, isError: false, meta: resource(null, { - sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, }).meta, }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 898ea88..a18b264 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -16,14 +16,14 @@ function section(query: { isError: boolean; meta?: InvestmentResourceMeta; }): SectionState { - const isError = query.isError || query.meta?.sync.state === "failed"; + const isError = query.isError || query.meta?.lastAttempt.state === "failed"; const hasData = hasSectionData(query.data); return { error: isError, hasData, loading: !hasData && !isError, - partial: query.meta?.availability === "partial", + partial: query.meta?.completeness === "incomplete", }; } diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index 4dd8dab..28b35d0 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -19,12 +19,14 @@ function auth() { } describe("read-only screen synchronization", () => { - it("retries the resource with GET and never starts a sync POST", async () => { + it("retries the resource with GET without requesting source observation", async () => { auth(); let gets = 0; + const syncModes: (null | string)[] = []; server.use( - http.get("*/api/v1/brokerage-accounts", () => { + http.get("*/api/v1/brokerage-accounts", ({ request }) => { gets += 1; + syncModes.push(new URL(request.url).searchParams.get("sync")); return gets === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) @@ -49,6 +51,7 @@ describe("read-only screen synchronization", () => { await waitFor(() => expect(result.current.accounts.data).toEqual([])); expect(gets).toBe(2); + expect(syncModes).toEqual([null, null]); }); it("refreshes the currently open resources together", async () => { diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 5eed0b3..fda4122 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -8,7 +8,6 @@ type ScreenResource = { isError: boolean; isFetching: boolean; meta?: InvestmentResourceMeta; - observe: () => Promise; queryKey: QueryKey; refetch: () => Promise; }; @@ -42,14 +41,12 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) { await Promise.allSettled(resources.map(resource => resource.refetch())); }; - // Retry failed HTTP reads without asking the source to import again. + // Retry only the HTTP read; source attempts are reported in resource metadata. const retry = async () => { - const failed = resources.filter( - resource => resource.isError, - ); + const failed = resources.filter(resource => resource.isError); if (failed.length > 0) - await Promise.allSettled(failed.map(resource => resource.observe())); + await Promise.allSettled(failed.map(resource => resource.refetch())); else await refresh(); }; @@ -57,7 +54,9 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) { refresh, refreshing: isRefreshing, retry, - sourceError: resources.some(({ meta }) => meta?.sync.state === "failed"), + sourceError: resources.some( + ({ meta }) => meta?.lastAttempt.state === "failed", + ), transportError: resources.some(resource => resource.isError), }; } diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index f2694f5..d9752ab 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -123,9 +123,10 @@ describe("Upcoming event date filters", () => { resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async const snapshot = resource( { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, - { sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, + { + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", + state: "failed", + }, + }, ); server.use( diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index bde07ed..0f3b0e4 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -51,8 +51,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -85,8 +86,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -138,8 +140,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: false, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, @@ -194,8 +197,9 @@ describe("AccountEventsPage", () => { isError: false, isLoading: true, meta: { - availability: "complete", - sync: { errorCode: null, id: null, state: "idle" }, + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, }, refetch: vi.fn(), } as never, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index f947b85..41be198 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,6 +1,8 @@ import { useState } from "react"; import { useParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageEvent, useBrokerageEvents, @@ -25,6 +27,7 @@ type AccountEventsPageViewModel onPeriodPopoverOpen: () => void; onReset: () => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; } | { onReset: () => void; state: "error" } @@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { const availability = useInitialAvailability({ hasContent: !!events.data, identity: scope, - isError: events.isError || events.meta?.sync.state === "failed", + isError: events.isError || events.meta?.lastAttempt.state === "failed", isFetching: events.isFetching, }); @@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { setSearchParameters({}); }; - if ((events.isError || events.meta?.sync.state === "failed") && !events.data) + if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data) return { onReset, state: "error", @@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { ), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onReset, - partial: events.meta?.availability !== "complete", + partial: events.meta?.completeness === "incomplete", + source: events.meta, state: "loaded", }; } diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 1b6b2bc..ee44db1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -15,6 +15,7 @@ import { BrokerageEventCard, brokerageEventKey, } from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC = (props) => { {t("events.title")} + + {!!props.partial && {t("investment.partial")}} {t("events.hint")} diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts index 01ce9e7..ce68f74 100644 --- a/src/pages/account-income/model/display.test.ts +++ b/src/pages/account-income/model/display.test.ts @@ -136,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k const previous = { ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed" as const, }, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, }; const year = { diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index 59a2679..bcd1b7c 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -66,7 +66,7 @@ function comparisonNotice( ): ComparisonNotice { if (!dependency || dependency.unavailableReason) return unavailable; - return dependency.sync.state === "failed" + return dependency.lastAttempt.state === "failed" ? "comparisonFailed" : "comparisonPending"; } diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 8d1676d..063be7e 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -205,9 +205,10 @@ describe("AccountIncomePage", () => { resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -832,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin }, { ...resource(null).meta, - role: "year" as const, - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed" as const, }, + role: "year" as const, }, { ...resource(analytics).meta, diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 7d4bc42..853fcae 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const availability = useInitialAvailability({ hasContent: !!analytics.data, identity: `${accountId}:${period.from}:${period.to}`, - isError: analytics.isError || analytics.meta?.sync.state === "failed", + isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed", isFetching: analytics.isFetching, }); @@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, onReset, onSubmit, - partial: analytics.meta?.availability !== "complete", + partial: analytics.meta?.completeness === "incomplete", state: "loaded", }; }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 6efda10..bf09049 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -507,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => resource( null, { - sync: { - errorCode: "TBANK_UNAVAILABLE", - id: null, + lastAttempt: { + finishedAt: "2026-08-14T00:01:00Z", + problems: [], + startedAt: "2026-08-14T00:00:00Z", state: "failed", }, }, @@ -543,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy items: [], nextCursor: null, }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 7449ac9..55eb5f2 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageOperation, brokerageOperationsQueryOptions, @@ -39,6 +41,7 @@ type AccountOperationsPageViewModel onPrevious: () => void; onReset: () => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; } | { onReset: () => void; state: "error" } @@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const availability = useInitialAvailability({ hasContent: !!operations.data, identity: scope, - isError: operations.isError || operations.meta?.sync.state === "failed", + isError: operations.isError || operations.meta?.lastAttempt.state === "failed", isFetching: operations.isFetching, }); @@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { if ( (isInvalidCursor || !operations.data) - && (operations.isError || operations.meta?.sync.state === "failed") + && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) return { onReset, state: "error" }; @@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPrevious: history.onPrevious, onReset, - partial: operations.meta?.availability !== "complete", + partial: operations.meta?.completeness === "incomplete", + source: operations.meta, state: "loaded", }; }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 208ff5f..40d513a 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; +import { SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC = ( {t("ledger.title")} + + {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}} diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts index b7bffa8..55e255e 100644 --- a/src/pages/account/model/previews.ts +++ b/src/pages/account/model/previews.ts @@ -46,9 +46,10 @@ type PreviewResource = { isError: boolean; meta?: Pick; }; @@ -57,10 +58,10 @@ function toIncomeSummary( income: PreviewResource, ): AccountPageIncomeSummary { const to = `/accounts/${accountId}/income`; - if (!income.data && !income.isError && income.meta?.sync.state !== "failed") + if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed") return { state: "loading", to }; - if (income.meta?.availability === "partial") return { state: "partial", to }; - if (!income.data && (income.isError || income.meta?.sync.state === "failed")) + if (income.meta?.completeness === "incomplete") return { state: "partial", to }; + if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed")) return { state: "error", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; @@ -76,15 +77,15 @@ function toRecentEvents( events: PreviewResource<{ events: BrokerageEvent[] }>, ): AccountPageRecentEvents { const to = `/accounts/${accountId}/events`; - if (!events.data && !events.isError && events.meta?.sync.state !== "failed") + if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed") return { state: "loading", to }; - if (events.meta?.availability === "partial") + if (events.meta?.completeness === "incomplete") return { items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], state: "partial", to, }; - if (!events.data && (events.isError || events.meta?.sync.state === "failed")) + if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed")) return { state: "error", to }; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; if (items.length === 0) return { state: "empty", to }; @@ -104,14 +105,14 @@ function toRecentOperations( if ( !operations.data && !operations.isError - && operations.meta?.sync.state !== "failed" + && operations.meta?.lastAttempt.state !== "failed" ) return { state: "loading", to }; - if (operations.meta?.availability === "partial") + if (operations.meta?.completeness === "incomplete") return { items: operations.data?.items ?? [], state: "partial", to }; if ( !operations.data - && (operations.isError || operations.meta?.sync.state === "failed") + && (operations.isError || operations.meta?.lastAttempt.state === "failed") ) return { state: "error", to }; if (!operations.data?.items.length) return { state: "empty", to }; @@ -120,7 +121,7 @@ function toRecentOperations( items: operations.data.items, lastSuccessfulAt: operations.meta?.lastSuccessfulAt, refreshError: - operations.isError || operations.meta?.sync.state === "failed", + operations.isError || operations.meta?.lastAttempt.state === "failed", stale: operations.meta?.freshness === "stale", state: "loaded", to, diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index b6baf4e..62d0b39 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a mockLoadedAccountPage(); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json(resource(portfolio, { availability: "partial" })), + HttpResponse.json(resource(portfolio, { completeness: "incomplete" })), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), @@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a HttpResponse.json( resource( { events: [], incomeTotalsByCurrency: [] }, - { availability: "partial" }, + { completeness: "incomplete" }, ), ), ), diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 675e83f..fe1a61b 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,6 +1,7 @@ import { useParams } from "react-router"; import type { Portfolio } from "@/entities/portfolio"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { useBrokerageOperations } from "@/entities/broker-operation"; import { @@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + source?: InvestmentResourceMeta; state: "loaded"; }; @@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel { const availability = useInitialAvailability({ hasContent: !!portfolio.data, identity: accountId ?? "account", - isError: portfolio.isError || portfolio.meta?.sync.state === "failed", + isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed", isFetching: portfolio.isFetching, }); @@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel { const incomeSummary = toIncomeSummary(accountId, income); if ( - portfolio.meta?.availability === "complete" + portfolio.meta?.availability === "available" && portfolio.data.positions.length === 0 && portfolio.data.cashBalances.length === 0 ) @@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel { state: "empty" as const, }; - const isPartial = portfolio.meta?.availability === "partial"; + const isPartial = portfolio.meta?.completeness === "incomplete"; return { account, @@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel { positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, + source: portfolio.meta, state: "loaded" as const, }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 01aa1f6..1dde06a 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { Portfolio } from "@/entities/portfolio"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { AccountPagePositionSection } from "../../../model/positions"; @@ -32,6 +34,7 @@ type AccountPageLoadedProps = { positionSections: AccountPagePositionSection[]; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; const AccountPageLoaded: FC = (props) => { @@ -49,6 +52,12 @@ const AccountPageLoaded: FC = (props) => { + + {props.partial ? {t("investment.partial")} : } diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index ff69dd7..851d087 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -189,14 +189,19 @@ describe("AccountsPage", () => { }); }); -it.each(["idle", "failed"] as const)( - "keeps partial empty membership distinct from no accounts (sync=%s)", +it.each(["none", "failed"] as const)( + "keeps incomplete empty membership distinct from no accounts (attempt=%s)", async (state) => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([], { - availability: "partial", - sync: { errorCode: null, id: null, state }, + completeness: "incomplete", + lastAttempt: { + finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null, + problems: [], + startedAt: state === "none" ? null : "2026-08-14T00:00:00Z", + state, + }, })), ), ); @@ -205,7 +210,7 @@ it.each(["idle", "failed"] as const)( await waitFor(() => expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ data: [], - meta: { availability: "partial" }, + meta: { completeness: "incomplete" }, }), ); expect(screen.queryByText("Нет инвестиционных счетов")) diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 40c24d5..8e02cdc 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -15,7 +15,7 @@ const AccountsPage: FC = () => { return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index b4e244d..4f3252c 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -4,17 +4,15 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode, type paths } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; type AccountsPageViewModel - = | { accounts: BrokerageAccountCard[]; state: "loaded"; sync: AccountsSync } + = | { accounts: BrokerageAccountCard[]; meta: NonNullable["meta"]>; state: "loaded" } | { code?: string; state: "error" } | { state: "empty" } | { state: "loading" }; -type AccountsSync = paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["sync"]; - function isAccountsAccessFailure(code?: string) { return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); @@ -28,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { const isAccessFailure = isAccountsAccessFailure(code); const hasResult = !!accounts.data - && !(accounts.meta?.availability === "partial" && accounts.data.length === 0); + && !!accounts.meta + && !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0); const availability = useInitialAvailability({ hasContent: hasResult, identity: "accounts", - isError: accounts.isError || accounts.meta?.sync.state === "failed", + isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed", isFetching: accounts.isFetching, }); @@ -49,10 +48,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel { state: "empty" as const, }; + const meta = accounts.meta; + if (!meta) return { state: "loading" as const }; + return { - accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), + accounts: brokerageAccountCards(accounts.data, meta.dependencies), + meta, state: "loaded" as const, - sync: accounts.sync, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 5e4fbef..ec58f96 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -1,45 +1,24 @@ import type { FC } from "react"; -import { Badge, Button, Stack, Text, Title } from "@mantine/core"; +import { Button, Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type BrokerageAccountCard, BrokerageAccountList, } from "@/entities/brokerage-account"; +import { SourceSyncStatus } from "@/shared/ui"; type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; - sync: - | null - | undefined - | { - finishedAt: null | string; - sections: { - accountId?: string; - errorCode: null | string; - section: string; - }[]; - startedAt: string; - state: string; - }; + meta: InvestmentResourceMeta; }; const AccountsPageLoaded: FC = (props) => { const { t } = useTranslation(); - const sync = props.sync; - - const syncLabel = sync - ? new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "short", - }).format(new Date(sync.finishedAt ?? sync.startedAt)) - : null; - - const syncColor = sync?.state === "failed" || sync?.state === "partial" - ? "red" - : "gray"; return ( @@ -50,20 +29,11 @@ const AccountsPageLoaded: FC = (props) => { {t("accounts.subtitle")} - {sync && ( - - Последний обход: - {" "} - {syncLabel} - {" "} - - {sync.state} - - - )} +