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
+11 -13
View File
@@ -1,8 +1,4 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type BondPeriod = NonNullable<
NonNullable<
@@ -13,7 +9,7 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000;
function useBondSearch(query: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } },
}),
@@ -36,11 +32,11 @@ function useBond(identifier: string) {
identifier,
);
const byUid = useAuthenticatedQuery({
const byUid = useResourceQuery({
...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useAuthenticatedQuery({
const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
@@ -50,7 +46,7 @@ function useBond(identifier: string) {
return isUid ? byUid : byTicker;
}
function useBondMetrics(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } },
}),
@@ -59,7 +55,7 @@ function useBondMetrics(uid: string) {
});
}
function useBondQuote(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } },
}),
@@ -68,7 +64,7 @@ function useBondQuote(uid: string) {
});
}
function useBondCandles(uid: string, period: BondPeriod) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
@@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) {
});
}
function useBondPosition(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } },
}),
@@ -86,7 +83,8 @@ function useBondPosition(uid: string) {
});
}
function useBondCoupons(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
params: { path: { uid } },
}),
@@ -1,16 +1,14 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
type BrokerageOperation =
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
type BrokerageOperationsQuery = NonNullable<
OperationsEndpoint["parameters"]["query"]
type BrokerageOperation = NonNullable<
OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
>["items"][number];
type BrokerageOperationsQuery = Omit<
NonNullable<OperationsEndpoint["parameters"]["query"]>,
"sync"
>;
type OperationCategory = BrokerageOperation["category"];
@@ -56,7 +54,8 @@ function useBrokerageOperations(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageOperationsQueryOptions(accountId, query),
enabled: Boolean(accountId),
staleTime: 30_000,
@@ -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 { useBrokerageAccounts } from "./useBrokerageAccounts";
@@ -15,14 +16,16 @@ describe("useBrokerageAccounts", () => {
});
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]),
HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]),
),
),
);
@@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => {
http.get("*/api/v1/brokerage-accounts", () => {
calls += 1;
return calls === 1
? HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
])
? HttpResponse.json(
resource([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
@@ -1,19 +1,17 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
type BrokerageAccount =
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
function useBrokerageAccounts() {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
});
@@ -1,10 +1,18 @@
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
import type {
BrokerageAccount,
useBrokerageAccounts,
} from "../api/useBrokerageAccounts";
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type BrokerageAccountCard = {
source?: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] extends (infer T)[] | undefined
? T
: never;
account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null;
summary:
@@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards(
accounts: BrokerageAccount[],
dependencies: NonNullable<
ReturnType<typeof useBrokerageAccounts>["meta"]
>["dependencies"] = [],
): BrokerageAccountCard[] {
return accounts.map((account) => {
const source = dependencies.find(
(item) =>
item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
);
const summary = account.portfolioSummary;
if (!summary)
return {
source,
account,
positionCounts: null,
summary: { state: "unavailable" },
@@ -33,8 +49,9 @@ function brokerageAccountCards(
bonds: summary.bondPositionCount,
};
if (!summary.totalsByCurrency.length)
return { account, positionCounts, summary: { state: "empty" } };
return { source, account, positionCounts, summary: { state: "empty" } };
return {
source,
account,
positionCounts,
summary: {
@@ -185,3 +185,44 @@ export const LargeValues: Story = {
]),
},
};
export const IndependentFreshness: Story = {
name: "Независимая свежесть и ошибка сводок",
args: {
accounts: brokerageAccountCards(
[
syntheticAccount,
{
...syntheticAccount,
id: "waiting",
name: "Новый счёт",
portfolioSummary: null,
},
],
[
{
resourceKey: "portfolio/storybook-account",
dataRevision: "1",
stateVersion: "failed",
availability: "complete",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
sync: {
id: "failed",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
{
resourceKey: "portfolio/waiting",
dataRevision: "0",
stateVersion: "waiting",
availability: "missing",
freshness: "unknown",
lastSuccessfulAt: null,
sync: { id: "waiting", state: "running", errorCode: null },
},
],
),
},
};
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
@@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => {
).not.toBeInTheDocument();
});
});
it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards(
["fresh", "stale"].map((id) => ({
id,
name: id,
type: "BROKERAGE" as const,
openedOn: null,
portfolioSummary: {
stockPositionCount: 1,
bondPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
})),
[
resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" })
.meta,
resource(null, { resourceKey: "portfolio/stale", freshness: "stale" })
.meta,
],
);
render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList accounts={accounts} />
</MemoryRouter>
</MantineProvider>,
);
expect(
within(screen.getByRole("link", { name: /stale/ })).getByText(
"Данные требуют обновления",
),
).toBeVisible();
expect(
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
"Данные требуют обновления",
),
).not.toBeInTheDocument();
});
@@ -2,7 +2,7 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css";
@@ -52,8 +52,8 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation();
return (
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg">
{accounts.map(({ account, summary, positionCounts }) => (
<SimpleGrid className={classes.list} cols={1} spacing="lg">
{accounts.map(({ account, summary, positionCounts, source }) => (
<Card
component={Link}
to={`/accounts/${account.id}`}
@@ -92,6 +92,25 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
{source?.lastSuccessfulAt ? (
<Text size="xs" c="dimmed">
{t("investment.sourceTime", {
date: formatSnapshotTime(source.lastSuccessfulAt),
})}
</Text>
) : null}
{source?.sync.state === "running" ||
source?.sync.state === "queued" ? (
<Text size="xs" role="status">
{t("accounts.refreshing")}
</Text>
) : null}
{source?.sync.state === "failed" ? (
<Text size="xs">{t("accounts.refreshError")}</Text>
) : null}
{source?.freshness === "stale" ? (
<Text size="xs">{t("investment.stale")}</Text>
) : null}
</Stack>
{positionCounts ? (
<dl className={classes.positions}>
@@ -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([
+11 -11
View File
@@ -1,16 +1,15 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type IncomeSummary =
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
type IncomeAnalytics =
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"];
type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function useIncomeSummary(accountId: string | undefined) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-summary",
@@ -25,7 +24,8 @@ function useIncomeAnalytics(
accountId: string | undefined,
period: { from: string; to: string },
) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-analytics",
@@ -0,0 +1,144 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
type Portfolio,
portfolioQueryOptions,
usePortfolio,
} from "./usePortfolio";
const snapshot: Portfolio = {
positions: [],
totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
cashBalances: [],
structure: { currencies: [], unvaluedCategories: [] },
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false,
};
function auth() {
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => true,
clear: vi.fn(),
});
}
describe("persistent resource query lifecycle", () => {
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
auth();
const modes: (string | null)[] = [];
let observations = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observations += 1;
return HttpResponse.json(
resource(snapshot, {
stateVersion: observations > 1 ? "new" : "old",
}),
);
},
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => usePortfolio("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
expect(modes).toEqual([null, "observe", "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
});
it("does not convert missing or failed source data to empty financial success", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(
resource(null, {
sync: {
id: "work",
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
),
),
);
const { result } = renderHook(() => usePortfolio("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(false);
expect(result.current.meta?.availability).toBe("missing");
});
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) await blocked;
return HttpResponse.json(resource(snapshot));
},
),
);
const old = apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "a" } } },
);
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
params: { path: { accountId: "a" }, query: { sync: "observe" } },
});
release();
await rejected;
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth();
let denied = false;
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ params }) => {
if (params.accountId === "b") await blocked;
if (params.accountId === "a" && denied)
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json(resource(snapshot));
},
),
);
const { wrapper } = createQueryWrapper();
const { result } = renderHook(
() => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
{ wrapper },
);
await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
denied = true;
await act(() => result.current.a.refetch());
await waitFor(() => expect(result.current.a.data).toBeUndefined());
await act(async () => {
release();
await blocked;
});
expect(result.current.b.data).toBeUndefined();
expect(result.current.a.isError).toBe(true);
});
});
@@ -1,9 +1,9 @@
import { renderHook, waitFor } from "@testing-library/react";
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 { usePortfolio } from "./usePortfolio";
@@ -33,14 +33,16 @@ describe("usePortfolio", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1;
return calls === 1
? HttpResponse.json({
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false,
totalsByCurrency: [],
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
})
? HttpResponse.json(
resource({
lastSuccessfulAt: "2026-08-14T00:00:00Z",
isStale: false,
totalsByCurrency: [],
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
}),
)
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
+6 -8
View File
@@ -1,15 +1,12 @@
import {
apiQueryClient,
type paths,
useAuthenticatedQuery,
} from "@/shared/api";
import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const portfolioQueryPolicy = {
staleTime: 30_000,
} as const;
type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
type Portfolio = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function portfolioQueryOptions(accountId: string | undefined) {
return apiQueryClient.queryOptions(
@@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) {
}
function usePortfolio(accountId: string | undefined) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...portfolioQueryOptions(accountId),
enabled: Boolean(accountId),
...portfolioQueryPolicy,
+16 -10
View File
@@ -1,8 +1,9 @@
import {
apiQueryClient,
type paths,
unwrapResourceResult,
useAuthenticatedQueries,
useAuthenticatedQuery,
useResourceQuery,
} from "@/shared/api";
type StockPeriod = NonNullable<
@@ -14,7 +15,7 @@ type StockPeriod = NonNullable<
const STOCK_STALE_TIME_MS = 30_000;
function useStockSearch(query: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
@@ -35,7 +36,10 @@ function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
return new Map(
queries.map((query, index) => [uniqueUids[index], query.data]),
queries.map((query, index) => [
uniqueUids[index],
unwrapResourceResult(query).data,
]),
);
}
@@ -45,11 +49,11 @@ function useStock(identifier: string) {
identifier,
);
const byUid = useAuthenticatedQuery({
const byUid = useResourceQuery({
...stockQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useAuthenticatedQuery({
const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
@@ -59,7 +63,7 @@ function useStock(identifier: string) {
return isUid ? byUid : byTicker;
}
function useStockQuote(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
params: { path: { uid } },
}),
@@ -68,7 +72,7 @@ function useStockQuote(uid: string) {
});
}
function useStockCandles(uid: string, period: StockPeriod) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
@@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) {
});
}
function useStockPosition(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
params: { path: { uid } },
}),
@@ -86,7 +91,7 @@ function useStockPosition(uid: string) {
});
}
function useStockCompany(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
params: { path: { uid } },
}),
@@ -95,7 +100,8 @@ function useStockCompany(uid: string) {
});
}
function useStockDividends(uid: string) {
return useAuthenticatedQuery({
return useResourceQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
params: { path: { uid } },
}),