refactor: align frontend ownership boundaries

Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
2026-09-06 18:20:05 +03:00
parent 175727bc93
commit c97f62bd98
118 changed files with 2190 additions and 2254 deletions
@@ -20,12 +20,26 @@ type IncomeForecast =
events: BrokerageEvent[];
};
type IncomeDisplayModel = {
trend: {
currency: string;
buckets: {
period: IncomeAnalytics["buckets"][number]["period"];
total: IncomeAnalytics["totalsByCurrency"][number];
}[];
}[];
instruments: (IncomeAnalytics["instruments"][number] & {
entries: IncomeAnalytics["entries"];
})[];
};
type AccountIncomePageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void; period: IncomePeriod }
| {
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
forecast: IncomeForecast;
form: ReturnType<typeof useForm<IncomeForm>>;
onReset: () => void;
@@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
return {
state: "loaded",
analytics: analytics.data,
display: toIncomeDisplayModel(analytics.data),
forecast: toForecast(events),
form,
onSubmit,
@@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
datePattern.test(value) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
for (const entry of analytics.entries) {
const key = incomeEntryKey(entry);
entriesByInstrument.set(key, [
...(entriesByInstrument.get(key) ?? []),
entry,
]);
}
return {
trend: analytics.totalsByCurrency.map(({ currency }) => ({
currency,
buckets: analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
})),
})),
instruments: analytics.instruments.map((instrument) => ({
...instrument,
entries: entriesByInstrument.get(instrument.key) ?? [],
})),
};
}
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
const base = entry.instrumentUid
? `uid:${entry.instrumentUid}`
: entry.figi
? `figi:${entry.figi}`
: entry.ticker || entry.instrumentType
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
: `operation:${entry.id}`;
return `${base}:${entry.payment?.currency ?? ""}`;
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
@@ -144,4 +206,9 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`;
}
export { type AccountIncomePageViewModel, useAccountIncomePageViewModel };
export {
type AccountIncomePageViewModel,
isDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};