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:
@@ -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,
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user