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
+154 -112
View File
@@ -34,11 +34,124 @@ import { StockDividends } from "./components/StockEvents.view";
import classes from "./StockPage.module.css";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return;
const appearanceClass = vm.quote?.change?.value.startsWith("-")
? classes.negative
: classes.positive;
let quoteContent;
if (vm.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!vm.quote?.change && (
<Text className={appearanceClass} fw={600}>
{vm.quote?.change?.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
)}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!vm.quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
)}
</Group>
{!!vm.quote?.unavailable.length && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
)}
{!!vm.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
let companyMetricsContent;
if (vm.company) {
if (vm.company.metrics.length > 0) {
companyMetricsContent = (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
);
}
else {
companyMetricsContent = (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
);
}
}
return (
<Stack className={classes.page} gap="lg">
<Paper className={classes.section} component="header" withBorder>
@@ -53,21 +166,21 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
>
{stock.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{stock.name}
</Title>
<Text c="dimmed" size="sm">
{stock.ticker}
{" "}
·
{" "}
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
</div>
</div>
<Button
disabled={vm.refreshing}
onClick={vm.refresh}
@@ -76,108 +189,48 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{t("investment.refresh")}
</Button>
</Group>
{!!(vm.company?.description) && ((
{!!vm.company?.description && (
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
))}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
)}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!(vm.quote?.change) && ((
<Text
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
fw={600}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
))}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!(vm.quote?.priceAt) && ((
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
))}
</Group>
{!!(vm.quote?.unavailable.length) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
))}
{!!(vm.quoteState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
</div>
)}
)}
{quoteContent}
<StockSection
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{!!(stock.sector) && ((
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}
:
{" "}
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
))}
{stock.lot !== null && ((
)}
{stock.lot !== null && (
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
))}
)}
</Group>
{!!(vm.company) && ((
{!!vm.company && (
<Stack gap="lg">
{vm.company.unavailable.length > 0 && ((
{vm.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
@@ -187,49 +240,38 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Button>
</Group>
</Alert>
))}
{vm.company.metrics.length > 0
? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}
{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
)
: (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
))}
)}
</StockSection>
</Paper>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends vm={vm} />
<StockEvents vm={vm} />
</Stack>
);
};
function metricUnit(metric: NonNullable<StockPageVm["company"]>["metrics"][number], unknownCurrency: string) {
if (metric.unit === "money") return metric.currency === "RUB" ? "₽" : (metric.currency ?? unknownCurrency);
function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money")
return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}