feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
@@ -4,6 +4,7 @@ import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import {
@@ -17,7 +18,16 @@ import {
const ISO_MONTH_LENGTH = 7;
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "yearUnavailable";
type IncomeDisplayModel = {
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
@@ -38,6 +48,9 @@ type AccountIncomePageViewModel =
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
formRevision: number;
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
@@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = (
});
const [formRevision, setFormRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ screen: "income", accountId, from: period.from, to: period.to },
[analytics],
);
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
@@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = (
setSearchParams(next, { replace: true });
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError)
return { state: "error", retry: () => analytics.refetch(), period };
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data);
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return { state: "error", retry: () => analytics.refetch(), period };
return {
state: "error",
retry: () => {
void analytics.observe();
},
period,
};
return {
state: "loaded",
analytics: analytics.data,
display,
formRevision,
refreshing: analytics.isFetching,
refreshError: analytics.isError,
retry: () => analytics.refetch(),
sourceError: sync.sourceError,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
form,
onSubmit,
onReset,
@@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
@@ -209,33 +236,53 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
return {
entries,
comparison: analytics.yearComparison
? {
selectedPeriod: formatComparisonPeriod(analytics.period),
comparedPeriod: formatComparisonPeriod(
analytics.yearComparison.period,
),
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
}
: null,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return null;
return {
selectedPeriod: formatComparisonPeriod(period),
comparedPeriod: formatComparisonPeriod(comparison.period),
values: comparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
};
}