174 lines
4.6 KiB
TypeScript
174 lines
4.6 KiB
TypeScript
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 };
|