refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
export { section } from "./model/section";
export { useInstrumentOperations } from "./model/useInstrumentOperations";
export { InstrumentChart } from "./ui/InstrumentChart.view";
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentSection } from "./ui/InstrumentSection.view";
@@ -9,6 +9,7 @@ describe("instrument source and transport presentation", () => {
"keeps %s separate from successful transport",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
const state = section({
data,
isError: false,
@@ -16,6 +17,7 @@ describe("instrument source and transport presentation", () => {
meta: resource(data, { availability }).meta,
refetch: vi.fn(),
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
@@ -24,13 +26,16 @@ describe("instrument source and transport presentation", () => {
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
const retry = vi.fn();
const refetch = vi.fn();
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta;
const state = section(
{ data: [], isError: false, isFetching: false, meta, refetch },
retry,
);
expect(state).toMatchObject({
error: true,
hasData: true,
@@ -43,17 +48,16 @@ describe("instrument source and transport presentation", () => {
expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section((
{
data: undefined,
isError: false,
isFetching: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta,
refetch: vi.fn(),
}
));
const state = section({
data: undefined,
isError: false,
isFetching: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
}).meta,
refetch: vi.fn(),
});
expect(state).toMatchObject({
error: true,
hasData: false,
@@ -2,6 +2,7 @@ import type { paths } from "@/shared/api";
type Meta
= paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type SectionState = {
error: boolean;
hasData: boolean;
@@ -13,6 +14,7 @@ type SectionState = {
sourceError?: boolean;
stale?: boolean;
};
function section(
query: {
data: unknown;
@@ -24,6 +26,7 @@ function section(
retry?: () => unknown,
): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
return {
error: isError,
hasData: Boolean(query.data),
@@ -6,7 +6,9 @@ import type { SectionState } from "./section";
type Candles = NonNullable<
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type ChartRow = Candles["candles"][number] & { date: string; price: number };
type InstrumentChartVm = {
averageLine: null | number;
candles?: Candles;
@@ -18,6 +20,7 @@ type InstrumentChartVm = {
priceRows: ChartRow[];
setPeriod: (value: string) => void;
};
type InstrumentEventsVm = {
eventRows: BrokerageOperation[];
filter: string;
@@ -28,10 +31,12 @@ type InstrumentEventsVm = {
operationsState: SectionState;
setFilter: (value: string) => void;
};
type InstrumentPositionVm = {
position?: Position;
positionState: SectionState;
};
type Position = NonNullable<
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
@@ -14,19 +14,28 @@ function useInstrumentOperations(
categories: OperationCategory[],
) {
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
cursors: string[];
scope: string;
}>({ cursors: [], scope });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const firstPage = { categories, instrumentUid: uid, limit: 20 };
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const retry = (retryScreen: () => unknown) => {
if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
setPagination({ cursors: [], scope });
void client.invalidateQueries({
exact: true,
@@ -35,6 +44,7 @@ function useInstrumentOperations(
}
else void retryScreen();
};
return {
hasPrevious: cursors.length > 0,
onNext: () => {
@@ -1,197 +0,0 @@
import type { FC } from "react";
import { AreaChart } from "@mantine/charts";
import {
Accordion,
Badge,
ScrollArea,
SegmentedControl,
Table,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatDecimal, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../model/types";
import classes from "./InstrumentDetails.module.css";
import { InstrumentSection } from "./InstrumentSection.view";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const priceLabel = (value: string) => {
if (props.priceUnit === "percent") return `${formatDecimal(value)} %`;
const currency = vm.candles?.currency;
return formatMoney(currency ? { currency, value } : undefined);
};
return (
<InstrumentSection
controls={(
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.period")}
data={["month", "halfYear", "year", "all"].map(value => ({
label: t(`stock.periods.${value}`),
value,
}))}
onChange={vm.setPeriod}
value={vm.period}
/>
</div>
)}
large
state={vm.candlesState}
title={t("stock.chart")}
>
{!!(vm.candles) && ((
vm.chartData.length > 0
? (
<>
<AreaChart
areaProps={{ isAnimationActive: false }}
aria-label={t(
props.priceUnit === "percent"
? "bond.chartLabel"
: "stock.chartLabel",
{
currency:
props.priceUnit === "percent"
? t("bond.percentOfNominal")
: (vm.candles.currency ?? ""),
},
)}
curveType="linear"
data={vm.chartData}
dataKey="date"
gridAxis="y"
h={310}
referenceLines={
vm.averageLine === null
? []
: [
{
color: "orange.7",
ifOverflow: "extendDomain",
label: t("stock.averageLine"),
y: vm.averageLine,
},
]
}
series={[
{ color: "blue.6", label: t("stock.price"), name: "price" },
]}
tickLine="none"
tooltipAnimationDuration={0}
valueFormatter={value =>
new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 4,
}).format(value)}
withDots={vm.chartData.length === 1}
withGradient
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
/>
<Text c="dimmed" mt="sm" size="xs">
{t(
props.priceUnit === "percent"
? "bond.chartNote"
: "stock.chartNote",
)}
</Text>
{!!(vm.hasIncompleteCandle) && ((
<Text c="dimmed" size="xs">
{t("stock.incomplete")}
</Text>
))}
{vm.averageLine !== null && ((
<Text mt="xs" size="sm">
{t("stock.averageLine")}
:
{" "}
{formatMoney(vm.position?.averagePurchasePrice)}
</Text>
))}
<Accordion
className={classes.priceTable}
radius="md"
variant="contained"
>
<Accordion.Item value="prices">
<Accordion.Control>
<Text fw={600}>{t("stock.priceTable")}</Text>
<Text c="dimmed" size="xs">
{t("stock.priceTableHint", { count: vm.chartData.length })}
</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize
mah={360}
type="auto"
viewportProps={{
"aria-label": t("stock.priceTable"),
"role": "region",
"tabIndex": 0,
}}
>
<Table
highlightOnHover
horizontalSpacing="md"
stickyHeader
striped
verticalSpacing="sm"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("stock.date")}</Table.Th>
<Table.Th>{t("stock.price")}</Table.Th>
<Table.Th>{t("stock.candle")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{vm.priceRows.map(candle => (
<Table.Tr key={candle.time}>
<Table.Td>{candle.date}</Table.Td>
<Table.Td>
{priceLabel(candle.close)}
</Table.Td>
<Table.Td>
<Badge
color={candle.isComplete ? "gray" : "blue"}
size="sm"
variant="light"
>
{t(
candle.isComplete
? "stock.complete"
: "stock.forming",
)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
)
: (
<Text c="dimmed">
{t(
vm.candlesState.partial
? "investment.partial"
: "stock.noCandles",
)}
</Text>
)
))}
</InstrumentSection>
);
};
export { InstrumentChart };
@@ -0,0 +1,195 @@
import type { FC } from "react";
import { AreaChart } from "@mantine/charts";
import {
Accordion,
Badge,
ScrollArea,
SegmentedControl,
Table,
Text,
} from "@mantine/core";
import { formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types";
import classes from "../InstrumentDetails.module.css";
import { InstrumentSection } from "../InstrumentSection.view";
import { useInstrumentChartVm } from "./InstrumentChart.vm";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const {
chartLabel,
formatChartValue,
periodOptions,
priceRows,
referenceLines,
t,
vm,
} = useInstrumentChartVm(props);
let chartContent;
if (vm.candles) {
if (vm.chartData.length > 0) {
chartContent = (
<>
<AreaChart
areaProps={{ isAnimationActive: false }}
aria-label={chartLabel}
curveType="linear"
data={vm.chartData}
dataKey="date"
gridAxis="y"
h={310}
referenceLines={referenceLines}
series={[
{ color: "blue.6", label: t("stock.price"), name: "price" },
]}
tickLine="none"
tooltipAnimationDuration={0}
valueFormatter={formatChartValue}
withDots={vm.chartData.length === 1}
withGradient
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
/>
<Text c="dimmed" mt="sm" size="xs">
{t(
props.priceUnit === "percent"
? "bond.chartNote"
: "stock.chartNote",
)}
</Text>
{!!vm.hasIncompleteCandle && (
<Text c="dimmed" size="xs">
{t("stock.incomplete")}
</Text>
)}
{vm.averageLine !== null && (
<Text mt="xs" size="sm">
{t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice)}
</Text>
)}
<Accordion
className={classes.priceTable}
radius="md"
variant="contained"
>
<Accordion.Item value="prices">
<Accordion.Control>
<Text fw={600}>{t("stock.priceTable")}</Text>
<Text c="dimmed" size="xs">
{t("stock.priceTableHint", { count: vm.chartData.length })}
</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize
mah={360}
type="auto"
viewportProps={{
"aria-label": t("stock.priceTable"),
"role": "region",
"tabIndex": 0,
}}
>
<Table
highlightOnHover
horizontalSpacing="md"
stickyHeader
striped
verticalSpacing="sm"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("stock.date")}</Table.Th>
<Table.Th>{t("stock.price")}</Table.Th>
<Table.Th>{t("stock.candle")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{priceRows.map((candle) => {
const displayColor = candle.isComplete
? "gray"
: "blue";
return (
<Table.Tr key={candle.time}>
<Table.Td>{candle.date}</Table.Td>
<Table.Td>
{candle.price}
</Table.Td>
<Table.Td>
<Badge
color={displayColor}
size="sm"
variant="light"
>
{t(
candle.isComplete
? "stock.complete"
: "stock.forming",
)}
</Badge>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);
}
else {
chartContent = (
<Text c="dimmed">
{t(
vm.candlesState.partial
? "investment.partial"
: "stock.noCandles",
)}
</Text>
);
}
}
return (
<InstrumentSection
controls={(
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.period")}
data={periodOptions}
onChange={vm.setPeriod}
value={vm.period}
/>
</div>
)}
large
state={vm.candlesState}
title={t("stock.chart")}
>
{chartContent}
</InstrumentSection>
);
};
export { InstrumentChart };
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import { formatChartValue, formatDecimal, formatMoney } from "@/shared/lib";
import type { InstrumentChartVm } from "../../model/types";
type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm };
function useInstrumentChartVm(props: InstrumentChartProps) {
const { vm } = props;
const { t } = useTranslation();
const priceRows = vm.priceRows.map((candle) => {
const currency = vm.candles?.currency;
const value = candle.close;
const amount = currency ? { currency, value } : undefined;
const price = props.priceUnit === "percent"
? `${formatDecimal(value)} %`
: formatMoney(amount);
return { ...candle, price };
});
const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({
label: t(`stock.periods.${value}`),
value,
}));
const chartLabel = t(
props.priceUnit === "percent" ? "bond.chartLabel" : "stock.chartLabel",
{
currency:
props.priceUnit === "percent"
? t("bond.percentOfNominal")
: (vm.candles?.currency ?? ""),
},
);
const referenceLines
= vm.averageLine === null
? []
: [
{
color: "orange.7",
ifOverflow: "extendDomain" as const,
label: t("stock.averageLine"),
y: vm.averageLine,
},
];
return {
chartLabel,
formatChartValue,
periodOptions,
priceRows,
referenceLines,
t,
vm,
};
}
export { useInstrumentChartVm };
@@ -20,25 +20,54 @@ type InstrumentEventsProps = {
const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const options = [
"all",
"trades",
props.incomeFilter ?? "dividends",
"other",
].map(value => ({
label:
value === (props.incomeFilter ?? "dividends") && props.incomeLabel
? props.incomeLabel
: t(`stock.filters.${value}`),
value,
}));
let eventsContent;
if (vm.eventRows.length > 0) {
eventsContent = (
<Stack gap="sm" mt="md">
{vm.eventRows.map(event => (
<LedgerEntryRow
key={`${event.account?.id}:${event.id}`}
operation={event}
withDetails
/>
))}
</Stack>
);
}
else {
eventsContent = (
<Text c="dimmed" py="lg">
{t(
vm.operationsState.partial
? "investment.partial"
: "stock.noOperations",
)}
</Text>
);
}
return (
<InstrumentSection
controls={(
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.eventFilter")}
data={[
"all",
"trades",
props.incomeFilter ?? "dividends",
"other",
].map(value => ({
label:
value === (props.incomeFilter ?? "dividends")
&& props.incomeLabel
? props.incomeLabel
: t(`stock.filters.${value}`),
value,
}))}
data={options}
onChange={vm.setFilter}
value={vm.filter}
/>
@@ -47,32 +76,14 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
state={vm.operationsState}
title={t("stock.history")}
>
{!!(vm.operations) && ((
{!!vm.operations && (
<>
<Text c="dimmed" size="xs">
{props.historyNote ?? t("stock.historyNote")}
</Text>
{vm.eventRows.length > 0
? (
<Stack gap="sm" mt="md">
{vm.eventRows.map(event => (
<LedgerEntryRow
key={`${event.account?.id}:${event.id}`}
operation={event}
withDetails
/>
))}
</Stack>
)
: (
<Text c="dimmed" py="lg">
{t(
vm.operationsState.partial
? "investment.partial"
: "stock.noOperations",
)}
</Text>
)}
{eventsContent}
<Group justify="space-between" mt="md">
<Button
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
@@ -81,6 +92,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
>
{t("stock.previous")}
</Button>
<Button
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
onClick={vm.onNext}
@@ -90,7 +102,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Button>
</Group>
</>
))}
)}
</InstrumentSection>
);
};
@@ -16,130 +16,148 @@ type InstrumentPositionProps = {
returnNote?: string;
vm: InstrumentPositionVm;
};
const InstrumentPosition: FC<InstrumentPositionProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
if (vm.position && vm.position.accounts.length === 0) return;
const positionReturnColor = vm.position?.absoluteReturn?.value.startsWith("-")
? "red.8"
: "green.8";
return (
<InstrumentSection state={vm.positionState} title={t("stock.position")}>
{!!(vm.position) && ((
{!!vm.position && (
<Stack gap="md">
{!!(vm.position.isStale) && ((
{!!vm.position.isStale && (
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
))}
)}
<div className={`${classes.metrics} ${classes.holding}`}>
<div>
<Text c="dimmed" size="sm">
{props.quantityLabel ?? t("stock.quantity")}
</Text>
<Text className={classes.metricValue}>
{formatDecimal(vm.position.quantity)}
</Text>
</div>
<div>
<Text c="dimmed" size="sm">
{t("stock.value")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.currentValue)}
</Text>
</div>
<div>
<Text c="dimmed" size="sm">
{t("stock.average")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.averagePurchasePrice)}
</Text>
</div>
<div>
<Text c="dimmed" size="sm">
{t("stock.openReturn")}
</Text>
<Text
c={
vm.position.absoluteReturn?.value.startsWith("-")
? "red.8"
: "green.8"
}
className={classes.metricValue}
>
<Text c={positionReturnColor} className={classes.metricValue}>
{formatMoney(vm.position.absoluteReturn)}
</Text>
{vm.position.returnPercent !== null && ((
{vm.position.returnPercent !== null && (
<Text size="sm">
{formatDecimal(vm.position.returnPercent)}
{" "}
%
{formatDecimal(vm.position.returnPercent)} %
</Text>
))}
)}
</div>
</div>
<Text c="dimmed" size="xs">
{props.returnNote ?? t("stock.openReturnNote")}
</Text>
<div>
{vm.position.accounts.map(account => (
<div className={classes.accountRow} key={account.account?.id}>
{account.account
? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(account.account.id)}`}
>
{account.account.name}
</Anchor>
)
: (
<Text>{t("stock.portfolio")}</Text>
)}
<div>
<Text c="dimmed" size="xs">
{props.quantityLabel ?? t("stock.quantity")}
</Text>
<Text>{formatDecimal(account.quantity)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.average")}
</Text>
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.value")}
</Text>
<Text>{formatMoney(account.currentValue)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.openReturn")}
</Text>
<Text
c={
(account.absoluteReturn?.value.startsWith("-")) ? ("red.8") : undefined
}
>
{formatMoney(account.absoluteReturn)}
</Text>
{account.returnPercent !== null && ((
<Text size="xs">
{formatDecimal(account.returnPercent)}
{" "}
%
{vm.position.accounts.map((account) => {
const accountReturnColor = account.absoluteReturn?.value.startsWith("-")
? "red.8"
: undefined;
return (
<div className={classes.accountRow} key={account.account?.id}>
{account.account
? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(account.account.id)}`}
>
{account.account.name}
</Anchor>
)
: (
<Text>{t("stock.portfolio")}</Text>
)}
<div>
<Text c="dimmed" size="xs">
{props.quantityLabel ?? t("stock.quantity")}
</Text>
))}
<Text>{formatDecimal(account.quantity)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.average")}
</Text>
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.value")}
</Text>
<Text>{formatMoney(account.currentValue)}</Text>
</div>
<div>
<Text c="dimmed" size="xs">
{t("stock.openReturn")}
</Text>
<Text c={accountReturnColor}>
{formatMoney(account.absoluteReturn)}
</Text>
{account.returnPercent !== null && (
<Text size="xs">
{formatDecimal(account.returnPercent)} %
</Text>
)}
</div>
</div>
</div>
))}
);
})}
</div>
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.position.observedAt),
})}
</Text>
</Stack>
))}
)}
</InstrumentSection>
);
};
@@ -29,12 +29,46 @@ type InstrumentSectionProps = PropsWithChildren<{
state: SectionState;
title: string;
}>;
const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
const { t } = useTranslation();
const readError = t(props.state.hasData ? "accounts.refreshError" : "stock.sectionError");
const readError = t(
props.state.hasData ? "accounts.refreshError" : "stock.sectionError",
);
const sectionClassName = props.embedded
? classes.embeddedSection
: classes.section;
const skeletonHeight = props.large
? CHART_SKELETON_HEIGHT_PX
: SECTION_SKELETON_HEIGHT_PX;
let sectionContent;
if (props.state.loading) {
sectionContent = (
<LoadingSkeleton
label={t("stock.loadingSection", { section: props.title })}
>
<Stack>
<Skeleton h={skeletonHeight} />
<Skeleton h={14} w="60%" />
</Stack>
</LoadingSkeleton>
);
}
else {
sectionContent = (
props.children
);
}
return (
<Paper
className={props.embedded ? classes.embeddedSection : classes.section}
className={sectionClassName}
component="section"
withBorder={!props.embedded}
>
@@ -42,34 +76,37 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
<Title order={2} size="h3">
{props.title}
</Title>
{props.controls}
</Group>
{!!(props.state.stale) && ((
<Text size="xs">{t("investment.stale")}</Text>
))}
{!!(props.state.partial) && ((
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!props.state.partial && (
<Text role="status">{t("investment.partial")}</Text>
))}
{!!(props.state.lastSuccessfulAt) && ((
)}
{!!props.state.lastSuccessfulAt && (
<Text c="dimmed" size="xs">
{t("investment.sourceTime", {
date: formatSnapshotTime(props.state.lastSuccessfulAt),
})}
</Text>
))}
{!!(props.state.refreshing) && ((
)}
{!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
{!!(props.state.error) && ((
)}
{!!props.state.error && (
<Alert aria-live="polite" color="orange" mb="sm" role="status">
<Group justify="space-between">
<Text size="sm">
{props.state.sourceError
? t("investment.syncFailed")
: readError}
{props.state.sourceError ? t("investment.syncFailed") : readError}
</Text>
<Button
disabled={props.state.refreshing}
onClick={props.state.retry}
@@ -80,27 +117,9 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
</Button>
</Group>
</Alert>
))}
{props.state.loading
? (
<LoadingSkeleton
label={t("stock.loadingSection", { section: props.title })}
>
<Stack>
<Skeleton
h={
props.large
? CHART_SKELETON_HEIGHT_PX
: SECTION_SKELETON_HEIGHT_PX
}
/>
<Skeleton h={14} w="60%" />
</Stack>
</LoadingSkeleton>
)
: (
props.children
)}
)}
{sectionContent}
</Paper>
);
};