feat: add quiet reading with rare confirmations

Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import type {
AccountPageIncomeSummary,
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
@@ -7,10 +7,10 @@ import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountPageErrorProps = { code?: string; retry: () => void };
type AccountPageErrorProps = { code?: string };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { code } = props;
const { t } = useTranslation();
if (isApiErrorCode(code, ApiErrorCode.NotFound))
return (
@@ -25,6 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -41,22 +42,11 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.connectionErrorTitle")}
/>
);
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
);
};
@@ -1,14 +1,12 @@
import type { FC } from "react";
import { Accordion, Alert, Stack, Text } from "@mantine/core";
import { Accordion, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { InvestmentRefreshState } from "@/features/tbank-connection";
import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPagePositionSection } from "../../../model/positions";
import type {
@@ -34,10 +32,6 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -55,27 +49,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
{!!props.portfolio.isStale && <Alert color="blue">{t("portfolio.staleWarning")}</Alert>}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
},
)}
</Text>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -8,30 +8,30 @@ import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
type IncomeSummaryProps
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "empty";
to: string;
}
| { state: "error"; to: string }
| {
state: "loaded";
summary: IncomeSummaryData;
to: string;
}
| { retry: () => void; state: "error"; to: string }
| { state: "empty"; to: string }
| { state: "loading"; to: string }
| { state: "partial"; to: string };
| {
state: "loading";
to: string;
}
| {
state: "partial";
to: string;
};
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial")
return (
<Text c="dimmed" role="status">
@@ -45,9 +45,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error")
return (
<StateView
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error
text={t("income.errorText")}
title={t("income.summaryError")}
@@ -66,12 +63,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={props.lastSuccessfulAt}
stale={props.stale}
/>
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -1,12 +1,11 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../../../model/previews";
@@ -18,28 +17,15 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
const { t } = useTranslation();
const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{operations.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={operations.lastSuccessfulAt}
stale={operations.stale}
/>
)}
{operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{operations.state === "error" && <Alert color="red">{t("ledger.recentError")}</Alert>}
{operations.state === "error" && <Text c="dimmed">{t("states.unavailableTitle")}</Text>}
{operations.state === "empty" && <Text c="dimmed">{t("ledger.recentEmpty")}</Text>}