Files
frontend/src/pages/account-income/model/display.ts
T
ksv741 94adbafa34
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m43s
refactor: simplify calendar and form state
2026-09-28 20:23:02 +03:00

174 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
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;
yearNotice: ComparisonNotice;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return 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 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 null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
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())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
entry.id,
occurrence,
entry.logoName,
]),
operation: entry,
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
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,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
export { type IncomeDisplayModel, toIncomeDisplayModel };