refactor: enforce account page view model boundary
This commit is contained in:
@@ -1,19 +1,102 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router";
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import { useBrokerageOperations } from "@/entities/broker-operation";
|
||||
import {
|
||||
type BrokerageAccount,
|
||||
brokerageAccountsQueryKey,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
instrumentLogoUrl,
|
||||
portfolioErrorCode,
|
||||
usePortfolio,
|
||||
} from "@/entities/portfolio";
|
||||
import { useAuth } from "@/features/auth";
|
||||
|
||||
export function useAccountPageViewModel() {
|
||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||
type AccountPageReturnTone = "positive" | "negative";
|
||||
|
||||
export type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
shareText: string | null;
|
||||
returnText: string | null;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
logoSrc: string | null;
|
||||
};
|
||||
|
||||
export type AccountPagePositionSection = {
|
||||
kind: PositionSectionKind;
|
||||
wide: boolean;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
export type AccountPageRecentOperations =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageOperation[];
|
||||
};
|
||||
|
||||
export type AccountPageRecentEvents =
|
||||
| {
|
||||
state: "loading" | "error" | "empty";
|
||||
to: string;
|
||||
}
|
||||
| {
|
||||
state: "loaded";
|
||||
to: string;
|
||||
items: BrokerageEvent[];
|
||||
};
|
||||
|
||||
type AccountPageContent = {
|
||||
account?: BrokerageAccount;
|
||||
accountId?: string;
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
};
|
||||
|
||||
type AccountPageLoadedViewModel = AccountPageContent & {
|
||||
state: "loaded";
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
};
|
||||
|
||||
type AccountPageEmptyViewModel = AccountPageContent & {
|
||||
state: "empty";
|
||||
};
|
||||
|
||||
export type AccountPageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; code?: string; retry: () => void }
|
||||
| AccountPageEmptyViewModel
|
||||
| AccountPageLoadedViewModel;
|
||||
|
||||
export function useAccountPageViewModel(): AccountPageViewModel {
|
||||
const { accountId } = useParams();
|
||||
const { accessToken } = useAuth();
|
||||
const portfolio = usePortfolio(accountId, accessToken);
|
||||
const operations = useBrokerageOperations(accountId, accessToken, {
|
||||
limit: 5,
|
||||
});
|
||||
const events = useBrokerageEvents(accountId, accessToken);
|
||||
const account = useQueryClient()
|
||||
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
|
||||
?.find((item) => item.id === accountId);
|
||||
const recentOperations = toRecentOperations(accountId, operations);
|
||||
const recentEvents = toRecentEvents(accountId, events);
|
||||
if (!portfolio.data) {
|
||||
if (portfolio.isError)
|
||||
return {
|
||||
@@ -33,15 +116,110 @@ export function useAccountPageViewModel() {
|
||||
state: "empty" as const,
|
||||
account,
|
||||
accountId,
|
||||
accessToken,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
...refresh,
|
||||
};
|
||||
return {
|
||||
state: "loaded" as const,
|
||||
account,
|
||||
accountId,
|
||||
accessToken,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
...refresh,
|
||||
};
|
||||
}
|
||||
|
||||
function toRecentOperations(
|
||||
accountId: string | undefined,
|
||||
operations: ReturnType<typeof useBrokerageOperations>,
|
||||
): AccountPageRecentOperations {
|
||||
const to = `/accounts/${accountId}/operations`;
|
||||
if (operations.isLoading) return { state: "loading", to };
|
||||
if (operations.isError) return { state: "error", to };
|
||||
if (!operations.data?.items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items: operations.data.items };
|
||||
}
|
||||
|
||||
function toRecentEvents(
|
||||
accountId: string | undefined,
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): AccountPageRecentEvents {
|
||||
const to = `/accounts/${accountId}/events`;
|
||||
if (events.isLoading) return { state: "loading", to };
|
||||
if (events.isError) return { state: "error", to };
|
||||
const items = events.data?.events.slice(0, 5) ?? [];
|
||||
if (!items.length) return { state: "empty", to };
|
||||
return { state: "loaded", to, items };
|
||||
}
|
||||
|
||||
export function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
for (const position of portfolio.positions) {
|
||||
if (!position.currentValue) continue;
|
||||
const currency = position.currentValue.currency;
|
||||
denominators.set(
|
||||
currency,
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
const stocks = toSection("stocks", true, portfolio, denominators);
|
||||
const bonds = toSection("bonds", true, portfolio, denominators);
|
||||
const other = toSection("other", false, portfolio, denominators);
|
||||
return [stocks, bonds, other].filter(
|
||||
(section) => section.positions.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function toSection(
|
||||
kind: PositionSectionKind,
|
||||
wide: boolean,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
kind,
|
||||
wide,
|
||||
positions: portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
if (kind === "bonds") return position.type === "BOND";
|
||||
return position.type !== "STOCK" && position.type !== "BOND";
|
||||
})
|
||||
.map((position) => toPosition(position, denominators, wide)),
|
||||
};
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
wide: boolean,
|
||||
): AccountPagePosition {
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
const returnTone =
|
||||
returnValue === null || returnValue === 0
|
||||
? undefined
|
||||
: returnValue > 0
|
||||
? "positive"
|
||||
: "negative";
|
||||
return {
|
||||
...position,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
value: String(denominators.get(position.currentValue.currency) ?? 0),
|
||||
})
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user