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
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents";
@@ -31,23 +32,25 @@ describe("useBrokerageEvents", () => {
});
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
HttpResponse.json({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
type: "dividend",
date: "2026-09-01",
dateKind: "registry",
figi: "figi-1",
instrumentUid: null,
ticker: "SBER",
displayName: "Сбер",
logoName: "sber3.png",
projectedAmount: { currency: "RUB", value: "12.34" },
},
],
}),
HttpResponse.json(
resource({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
type: "dividend",
date: "2026-09-01",
dateKind: "registry",
figi: "figi-1",
instrumentUid: null,
ticker: "SBER",
displayName: "Сбер",
logoName: "sber3.png",
projectedAmount: { currency: "RUB", value: "12.34" },
},
],
}),
),
),
);
@@ -1,19 +1,17 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
type BrokerageEvent =
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
type BrokerageEvent = NonNullable<
EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
>["events"][number];
function useBrokerageEvents(
accountId: string | undefined,
query: EventsEndpoint["parameters"]["query"] = {},
query: Omit<NonNullable<EventsEndpoint["parameters"]["query"]>, "sync"> = {},
) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/events",
@@ -3,6 +3,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatSnapshotTime } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventCard } from "../BrokerageEventCard";
@@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState =
| { state: "loading" | "error" | "empty"; to: string }
| { state: "loaded"; to: string; items: BrokerageEvent[] };
| {
state: "loaded" | "partial";
to: string;
lastSuccessfulAt?: string | null;
stale?: boolean;
refreshError?: boolean;
items: BrokerageEvent[];
};
const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
@@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
return (
<Stack gap="sm">
{events.state === "loaded" ? (
<>
{events.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(events.lastSuccessfulAt),
})}
</Text>
) : null}
{events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null}
{events.refreshError ? (
<Text role="status">{t("accounts.refreshError")}</Text>
) : null}
</>
) : null}
{events.state === "loading" ? (
<LoadingSkeleton label={t("events.recentLoading")}>
{[0, 1, 2].map((index) => (
@@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "empty" ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null}
{events.state === "loaded"
{events.state === "partial" ? (
<Text role="status">{t("investment.partial")}</Text>
) : null}
{events.state === "loaded" || events.state === "partial"
? events.items.map((event) => (
<BrokerageEventCard
key={JSON.stringify([