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
@@ -2,13 +2,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import { incomeFormSchema } from "../../model/income.schema";
import {
formatDraftDate,
type IncomeForm,
@@ -43,6 +42,7 @@ type AccountIncomePageViewModel
state: "error";
}
| { state: "loading" };
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
@@ -50,15 +50,18 @@ type ComparisonNotice
| "yearUnavailable";
type IncomeDisplayModel = {
comparison: null | {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon" | "dividend" | "total", null | string>;
})[];
};
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
@@ -67,33 +70,13 @@ type IncomeDisplayModel = {
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
const draftDate = z
.string()
.refine(value => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine(
(value) => {
const date = parseDraftDate(value);
return !date || date <= moscowDate(new Date());
},
{ error: validationMessage("income.validation.future") },
);
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
({ from, to }) => {
const start = parseDraftDate(from);
const end = parseDraftDate(to);
return !start || !end || start <= end;
},
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
);
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParameters, setSearchParameters] = useSearchParams();
const requested = useMemo(
() => ({
from: searchParameters.get("from") ?? "",
@@ -101,31 +84,39 @@ const useAccountIncomePageViewModel = (
}),
[searchParameters],
);
const period = useMemo(
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
[requested],
);
const form = useForm<IncomeForm>({
defaultValues: draftPeriod(period),
resolver: zodResolver(incomeFormSchema),
});
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
useEffect(() => {
if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true });
if (!isValidPeriod(requested))
setSearchParameters(period, { replace: true });
}, [period, requested, setSearchParameters]);
useEffect(() => {
form.reset(draftPeriod(period));
}, [form, period]);
if (analytics.isLoading || !analytics.data) {
return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" };
return analytics.isError || analytics.meta?.sync.state === "failed"
? { period, retry: sync.retry, state: "error" }
: { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
@@ -133,11 +124,13 @@ const useAccountIncomePageViewModel = (
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
const to = parseDraftDate(values.to);
if (from && to) setSearchParameters({ from, to });
};
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
@@ -145,6 +138,7 @@ const useAccountIncomePageViewModel = (
setResetRevision(revision => revision + 1);
setSearchParameters(next, { replace: true });
};
return {
analytics: analytics.data,
display,
@@ -166,7 +160,10 @@ function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!comparison)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
@@ -192,11 +189,11 @@ function comparisonDisplay(
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
@@ -204,6 +201,7 @@ function comparisonNotice(
function defaultPeriod(): IncomePeriod {
const today = moscowDate(new Date());
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
}
@@ -221,9 +219,14 @@ function formatIncomeChange(
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!Number.isFinite(base) || base <= 0)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (!Number.isFinite(ratio))
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
@@ -236,6 +239,7 @@ function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
const from = new Date(`${period.from}T00:00:00Z`);
const to = new Date(`${period.to}T00:00:00Z`);
const today = new Date(`${moscowDate(new Date())}T00:00:00Z`);
return (
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
@@ -256,9 +260,11 @@ function toIncomeDisplayModel(
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
@@ -274,8 +280,10 @@ function toIncomeDisplayModel(
receiptCount: instrument.receiptCount,
});
}
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
@@ -298,12 +306,16 @@ function toIncomeDisplayModel(
operation: entry,
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
if (instrument.includedCount !== instrument.receiptCount)
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
return null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,