chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
@@ -3,70 +3,73 @@ 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 {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
ISO_DATE_LENGTH,
moscowDate,
parseDraftDate,
} from "../../model/period";
const ISO_MONTH_LENGTH = 7;
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type AccountIncomePageViewModel
= | {
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
form: ReturnType<typeof useForm<IncomeForm>>;
formRevision: string;
lastSuccessfulAt?: null | string;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
state: "loaded";
}
| {
period: IncomePeriod;
retry: () => Promise<unknown> | undefined;
state: "error";
}
| { state: "loading" };
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
comparison: null | {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"total" | "coupon" | "dividend", string | null>;
percentages: Record<"coupon" | "dividend" | "total", null | string>;
})[];
} | null;
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
};
type AccountIncomePageViewModel =
| { state: "loading" }
| {
state: "error";
retry: () => undefined | Promise<unknown>;
period: IncomePeriod;
}
| {
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
formRevision: string;
sourceError?: boolean;
lastSuccessfulAt?: string | null;
partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => undefined | Promise<unknown>;
form: ReturnType<typeof useForm<IncomeForm>>;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
.refine(value => parseDraftDate(value) !== null, {
error: validationMessage("income.validation.date"),
})
.refine(
@@ -90,16 +93,16 @@ const useAccountIncomePageViewModel = (
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const [searchParameters, setSearchParameters] = useSearchParams();
const requested = useMemo(
() => ({
from: searchParams.get("from") ?? "",
to: searchParams.get("to") ?? "",
from: searchParameters.get("from") ?? "",
to: searchParameters.get("to") ?? "",
}),
[searchParams],
[searchParameters],
);
const period = useMemo(
() => (validPeriod(requested) ? requested : defaultPeriod()),
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
[requested],
);
const form = useForm<IncomeForm>({
@@ -109,60 +112,94 @@ const useAccountIncomePageViewModel = (
const [resetRevision, setResetRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const sync = useScreenSync(
{ screen: "income", accountId, from: period.from, to: period.to },
{ accountId, from: period.from, screen: "income", to: period.to },
[analytics],
);
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
}, [period, requested, setSearchParams]);
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" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
period,
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
const to = parseDraftDate(values.to);
if (from && to) setSearchParams({ from, to });
if (from && to) setSearchParameters({ from, to });
};
const onReset = () => {
const next = defaultPeriod();
form.reset(draftPeriod(next));
// Reset the open calendar along with the form.
setResetRevision((revision) => revision + 1);
setSearchParams(next, { replace: true });
setResetRevision(revision => revision + 1);
setSearchParameters(next, { replace: true });
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
return {
state: "error",
retry: () => analytics.observe(),
period,
};
return {
state: "loaded",
analytics: analytics.data,
display,
formRevision: `${period.from}:${period.to}:${resetRevision}`,
sourceError: sync.sourceError,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.retry,
form,
onSubmit,
formRevision: `${period.from}:${period.to}:${resetRevision}`,
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
onReset,
onSubmit,
partial: analytics.meta?.availability !== "complete",
refreshError: analytics.isError || sync.sourceError || sync.transportError,
refreshing: analytics.isFetching || sync.refreshing,
retry: sync.retry,
sourceError: sync.sourceError,
state: "loaded",
};
};
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
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;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function defaultPeriod(): IncomePeriod {
@@ -170,16 +207,40 @@ function defaultPeriod(): IncomePeriod {
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
}
function validPeriod(period: IncomePeriod): period is IncomePeriod {
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): 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;
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;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
if (!isDate(period.from) || !isDate(period.to)) return false;
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, 10) === period.from &&
to.toISOString().slice(0, 10) === period.to &&
from <= to &&
to <= today
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
&& from <= to
&& to <= today
);
}
@@ -191,8 +252,8 @@ function toIncomeDisplayModel(
string,
{
currency: string;
receiptCount: number;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
@@ -200,25 +261,28 @@ function toIncomeDisplayModel(
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string" ||
!instrument.key.trim() ||
instruments.has(instrument.key) ||
!currencies.has(instrument.total.currency)
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
receiptCount: instrument.receiptCount,
includedCount: 0,
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.
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
@@ -235,80 +299,20 @@ function toIncomeDisplayModel(
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount) return null;
if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ 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,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison) return null;
return {
selectedPeriod: formatComparisonPeriod(period),
comparedPeriod: formatComparisonPeriod(comparison.period),
values: comparison.totalsByCurrency.map((value) => ({
...value,
percentages: {
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
},
})),
};
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): string | null {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0) return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio)) return null;
return new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(ratio);
}
export {
type AccountIncomePageViewModel,
type IncomeDisplayModel,