refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user