feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
This commit was merged in pull request #74.
This commit is contained in:
@@ -2,168 +2,25 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
import { resource } from "@/test/resource";
|
||||
|
||||
import { toIncomeDisplayModel } from "./display";
|
||||
|
||||
const analytics: IncomeAnalytics = {
|
||||
accountId: "account-1",
|
||||
bucketGranularity: "day",
|
||||
buckets: [],
|
||||
calculatedAt: "2026-08-27T00:00:00Z",
|
||||
entries: [],
|
||||
instruments: [],
|
||||
period: { from: "2026-08-01", to: "2026-08-27" },
|
||||
previous: {
|
||||
period: { from: "2026-07-01", to: "2026-07-31" },
|
||||
totalsByCurrency: [comparison("RUB")],
|
||||
},
|
||||
totalsByCurrency: [],
|
||||
yearComparison: null,
|
||||
};
|
||||
describe("income payments display", () => {
|
||||
it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => {
|
||||
const payment = {
|
||||
id: "same",
|
||||
instrumentKey: "bond:RUB",
|
||||
logoName: null,
|
||||
} as IncomeAnalytics["entries"][number];
|
||||
|
||||
function comparison(currency: string) {
|
||||
return {
|
||||
compared: summary(currency, "1", "0"),
|
||||
currency,
|
||||
difference: summary(currency, "9", "2"),
|
||||
selected: summary(currency, "10", "2"),
|
||||
};
|
||||
}
|
||||
const analytics = {
|
||||
entries: [payment, payment, { ...payment, logoName: "bond.png" }],
|
||||
} as IncomeAnalytics;
|
||||
|
||||
function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
}
|
||||
const display = toIncomeDisplayModel(analytics);
|
||||
|
||||
function summary(currency: string, coupon: string, dividend: string) {
|
||||
return {
|
||||
coupon: money(currency, coupon),
|
||||
currency,
|
||||
dividend: money(currency, dividend),
|
||||
total: money(currency, String(Number(coupon) + Number(dividend))),
|
||||
};
|
||||
}
|
||||
|
||||
describe("income comparison display", () => {
|
||||
it.each([
|
||||
["25", "100", "+25 %"],
|
||||
["-25", "100", "-25 %"],
|
||||
["0", "100", "0 %"],
|
||||
["-0.000", "100", "0 %"],
|
||||
["0.0001", "100", "0 %"],
|
||||
["1", "3", "+33,3 %"],
|
||||
["0.01", "0.04", "+25 %"],
|
||||
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
|
||||
[
|
||||
"10",
|
||||
"0",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"0",
|
||||
"0",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"10",
|
||||
"-0.00",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"10",
|
||||
"-100",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"9".repeat(400),
|
||||
"1",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"1",
|
||||
"9".repeat(400),
|
||||
null,
|
||||
],
|
||||
])("formats change %s against base %s", (difference, base, expected) => {
|
||||
const value = comparison("RUB");
|
||||
value.difference.coupon = money("RUB", difference);
|
||||
value.compared.coupon = money("RUB", base);
|
||||
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
yearComparison: {
|
||||
period: { from: "2025-08-01", to: "2025-08-27" },
|
||||
totalsByCurrency: [value],
|
||||
},
|
||||
});
|
||||
|
||||
const result = display?.comparison?.values[0];
|
||||
expect(
|
||||
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
|
||||
|
||||
?? null,
|
||||
).toBe(expected);
|
||||
expect(result?.difference.coupon.value).toBe(difference);
|
||||
expect(result?.compared.coupon.value).toBe(base);
|
||||
});
|
||||
|
||||
it("formats calendar ranges across years and preserves separate currencies", () => {
|
||||
const display = toIncomeDisplayModel({
|
||||
...analytics,
|
||||
period: { from: "2025-12-01", to: "2026-01-09" },
|
||||
yearComparison: {
|
||||
period: { from: "2024-12-01", to: "2025-01-09" },
|
||||
totalsByCurrency: [comparison("RUB"), comparison("USD")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
|
||||
"01.12.2025 – 09.01.2026",
|
||||
expect(display.entries.map(({ operation }) => operation)).toEqual(
|
||||
analytics.entries,
|
||||
);
|
||||
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
|
||||
"01.12.2024 – 09.01.2025",
|
||||
);
|
||||
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
|
||||
"RUB",
|
||||
"USD",
|
||||
]);
|
||||
expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps comparison notices by role regardless of dependency order or resource key", () => {
|
||||
const selected = { ...resource(analytics).meta, role: "selected" as const };
|
||||
|
||||
const previous
|
||||
= {
|
||||
...resource(null).meta,
|
||||
lastAttempt: {
|
||||
finishedAt: "2026-08-14T00:01:00Z",
|
||||
problems: [],
|
||||
startedAt: "2026-08-14T00:00:00Z",
|
||||
state: "failed" as const,
|
||||
},
|
||||
resourceKey: "opaque/year-looking-key",
|
||||
role: "previous" as const,
|
||||
};
|
||||
|
||||
const year = {
|
||||
...resource(null).meta,
|
||||
resourceKey: "opaque/previous-looking-key",
|
||||
role: "year" as const,
|
||||
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
|
||||
};
|
||||
|
||||
for (const dependencies of [
|
||||
[selected, previous, year],
|
||||
[year, selected, previous],
|
||||
]) {
|
||||
const display = toIncomeDisplayModel(analytics, {
|
||||
...resource(analytics).meta,
|
||||
dependencies,
|
||||
});
|
||||
|
||||
expect(display?.previousNotice).toBe("comparisonFailed");
|
||||
expect(display?.yearNotice).toBe("yearUnavailable");
|
||||
expect(display?.previousComparison).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,102 +1,11 @@
|
||||
import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income";
|
||||
|
||||
import { formatDate } from "@/shared/lib";
|
||||
|
||||
import type { IncomePeriod } from "./period";
|
||||
|
||||
type ComparisonNotice
|
||||
= | "comparisonFailed"
|
||||
| "comparisonPending"
|
||||
| "previousUnavailable"
|
||||
| "yearUnavailable";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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:
|
||||
IncomeAnalyticsMeta["dependencies"][number] | undefined,
|
||||
unavailable: "previousUnavailable" | "yearUnavailable",
|
||||
): ComparisonNotice {
|
||||
if (!dependency || dependency.unavailableReason) return unavailable;
|
||||
|
||||
return dependency.lastAttempt.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?: IncomeAnalyticsMeta,
|
||||
): IncomeDisplayModel {
|
||||
const entries: IncomeDisplayModel["entries"] = [];
|
||||
const occurrences = new Map<string, number>();
|
||||
@@ -116,17 +25,7 @@ function toIncomeDisplayModel(
|
||||
});
|
||||
}
|
||||
|
||||
const dependencies = meta?.dependencies ?? [];
|
||||
const previous = dependencies.find(({ role }) => role === "previous");
|
||||
const year = 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"),
|
||||
};
|
||||
return { entries };
|
||||
}
|
||||
|
||||
export { type IncomeDisplayModel, toIncomeDisplayModel };
|
||||
|
||||
Reference in New Issue
Block a user