refactor: share source sync status presentation
This commit is contained in:
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageAccountCard } from "../../model/display";
|
import type { BrokerageAccountCard } from "../../model/display";
|
||||||
|
|
||||||
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrokerageAccountSourceStatusProps = {
|
||||||
|
source: BrokerageAccountCard["source"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { source } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isSyncing
|
||||||
|
= source?.sync.state === "running" || source?.sync.state === "queued";
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SourceSyncStatus
|
||||||
|
errorText={errorText}
|
||||||
|
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||||||
|
stale={source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<Text role="status" size="xs">
|
||||||
|
{t("accounts.refreshing")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||||
const { accounts } = props;
|
const { accounts } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
|
|
||||||
<BrokerageAccountSummary summary={summary} />
|
<BrokerageAccountSummary summary={summary} />
|
||||||
|
|
||||||
{!!source?.lastSuccessfulAt && (
|
<BrokerageAccountSourceStatus source={source} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(source.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(source?.sync.state === "running"
|
|
||||||
|| source?.sync.state === "queued") && (
|
|
||||||
<Text role="status" size="xs">
|
|
||||||
{t("accounts.refreshing")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
|
|
||||||
|
|
||||||
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{!!positionCounts && (
|
{!!positionCounts && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||||
|
|
||||||
@@ -33,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
const { events } = props;
|
const { events } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= events.state === "loaded" && events.refreshError
|
||||||
|
? t("accounts.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{events.state === "loaded" && (
|
{events.state === "loaded" && (
|
||||||
<>
|
<SourceSyncStatus
|
||||||
{!!events.lastSuccessfulAt && (
|
errorText={errorText}
|
||||||
<Text c="dimmed" size="xs">
|
lastSuccessfulAt={events.lastSuccessfulAt}
|
||||||
{t("investment.sourceTime", {
|
stale={events.stale}
|
||||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
/>
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{events.state === "loading" && (
|
{events.state === "loading" && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { SectionState } from "../model/section";
|
import type { SectionState } from "../model/section";
|
||||||
|
|
||||||
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
|||||||
{props.controls}
|
{props.controls}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
<SourceSyncStatus
|
||||||
|
lastSuccessfulAt={props.state.lastSuccessfulAt}
|
||||||
|
stale={props.state.stale}
|
||||||
|
/>
|
||||||
|
|
||||||
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
|
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
|
||||||
|
|
||||||
{!!props.state.lastSuccessfulAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!props.state.refreshing && (
|
{!!props.state.refreshing && (
|
||||||
<Text c="dimmed" role="status" size="xs">
|
<Text c="dimmed" role="status" size="xs">
|
||||||
{t("accounts.refreshing")}
|
{t("accounts.refreshing")}
|
||||||
|
|||||||
+2
-8
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
|||||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
type InvestmentRefreshStateProps = {
|
type InvestmentRefreshStateProps = {
|
||||||
labelScope?: "accounts" | "portfolio";
|
labelScope?: "accounts" | "portfolio";
|
||||||
@@ -20,13 +20,7 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
|
|||||||
+2
-8
@@ -16,7 +16,7 @@ import {
|
|||||||
BrokerageEventCard,
|
BrokerageEventCard,
|
||||||
brokerageEventKey,
|
brokerageEventKey,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||||
|
|
||||||
@@ -55,13 +55,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -13,7 +13,7 @@ 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 { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
@@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={classes.page} gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("income.title")}</Title>
|
<Title order={1}>{t("income.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -7,7 +7,6 @@ import {
|
|||||||
MultiSelect,
|
MultiSelect,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
@@ -15,7 +14,7 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||||
|
|
||||||
@@ -63,13 +62,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("ledger.title")}</Title>
|
<Title order={1}>{t("ledger.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Link } from "react-router";
|
|||||||
|
|
||||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||||
|
|
||||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
type IncomeSummaryProps
|
type IncomeSummaryProps
|
||||||
= | {
|
= | {
|
||||||
@@ -25,6 +26,12 @@ type IncomeSummaryProps
|
|||||||
|
|
||||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= props.state === "loaded" && props.refreshError
|
||||||
|
? t("income.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
if (props.state === "loading" || props.state === "partial")
|
if (props.state === "loading" || props.state === "partial")
|
||||||
return (
|
return (
|
||||||
<Text c="dimmed" role="status">
|
<Text c="dimmed" role="status">
|
||||||
@@ -59,17 +66,11 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus
|
||||||
<Text c="dimmed" size="xs">
|
errorText={errorText}
|
||||||
{t("investment.sourceTime", {
|
lastSuccessfulAt={props.lastSuccessfulAt}
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
stale={props.stale}
|
||||||
})}
|
/>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!props.refreshError && <Text role="status">{t("income.refreshError")}</Text>}
|
|
||||||
|
|
||||||
{props.summary.totalsByCurrency.map(total => (
|
{props.summary.totalsByCurrency.map(total => (
|
||||||
<Paper key={total.currency} p="sm" withBorder>
|
<Paper key={total.currency} p="sm" withBorder>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||||
|
|
||||||
@@ -18,22 +18,19 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { operations } = props;
|
const { operations } = props;
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= operations.state === "loaded" && operations.refreshError
|
||||||
|
? t("accounts.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{operations.state === "loaded" && (
|
{operations.state === "loaded" && (
|
||||||
<>
|
<SourceSyncStatus
|
||||||
{!!operations.lastSuccessfulAt && (
|
errorText={errorText}
|
||||||
<Text c="dimmed" size="xs">
|
lastSuccessfulAt={operations.lastSuccessfulAt}
|
||||||
{t("investment.sourceTime", {
|
stale={operations.stale}
|
||||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
/>
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!operations.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{operations.state === "loading" && (
|
{operations.state === "loading" && (
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { SourceSyncStatus, type SourceSyncStatusProps } from "./SourceSyncStatus";
|
||||||
|
|
||||||
|
function renderStatus(props: SourceSyncStatusProps) {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<SourceSyncStatus {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourceSyncStatus", () => {
|
||||||
|
it("renders nothing without source data", () => {
|
||||||
|
renderStatus({});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Данные/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the source timestamp", () => {
|
||||||
|
renderStatus({ lastSuccessfulAt: "2026-02-03T10:15:00.000Z" });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces the caller-provided error text as a status", () => {
|
||||||
|
renderStatus({ errorText: "Обновление не удалось" });
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the notices next to the timestamp they belong to", () => {
|
||||||
|
renderStatus({
|
||||||
|
errorText: "Обновление не удалось",
|
||||||
|
lastSuccessfulAt: "2026-02-03T10:15:00.000Z",
|
||||||
|
stale: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
|
// Owns the "when did the source last answer" line and the two notices that
|
||||||
|
// always travel with it. Surfaces keep their own busy indicator: a list card
|
||||||
|
// shows a muted line, a page header a progress bar — that is a product call.
|
||||||
|
type SourceSyncStatusProps = {
|
||||||
|
errorText?: null | string;
|
||||||
|
lastSuccessfulAt?: null | string;
|
||||||
|
stale?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!!props.lastSuccessfulAt && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||||
|
|
||||||
|
{!!props.errorText && <Text role="status">{props.errorText}</Text>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SourceSyncStatus, type SourceSyncStatusProps };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||||
Reference in New Issue
Block a user