import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; import type { BrokerageEvent } from "@/entities/brokerage-event"; import { useBrokerageEvents } from "@/entities/brokerage-event"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { validationMessage } from "@/shared/i18n"; type IncomePeriod = { from: string; to: string }; type IncomeForm = IncomePeriod; type IncomeForecast = | { state: "loading" | "empty" } | { state: "error"; retry: () => void } | { state: "loaded"; totals: { currency: string; value: string }[]; 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>; onReset: () => void; onSubmit: (values: IncomeForm) => void; }; const datePattern = /^\d{4}-\d{2}-\d{2}$/; const incomeFormSchema = z .object({ from: z .string() .refine(isDate, validationMessage("income.validation.date")), to: z.string().refine(isDate, validationMessage("income.validation.date")), }) .refine(validPeriod, { path: ["to"], ...validationMessage("income.validation.period")({}), }); const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const { accountId } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const requested = useMemo( () => ({ from: searchParams.get("from") ?? "", to: searchParams.get("to") ?? "", }), [searchParams], ); const period = useMemo( () => (validPeriod(requested) ? requested : defaultPeriod()), [requested], ); const form = useForm({ defaultValues: period, resolver: zodResolver(incomeFormSchema), }); const analytics = useIncomeAnalytics(accountId, period); const events = useBrokerageEvents(accountId); useEffect(() => { if (!validPeriod(requested)) setSearchParams(period, { replace: true }); }, [period, requested, setSearchParams]); useEffect(() => form.reset(period), [form, period]); const onSubmit = (values: IncomeForm) => setSearchParams(values); const onReset = () => setSearchParams(defaultPeriod(), { replace: true }); if (analytics.isLoading || !analytics.data) { if (analytics.isError) return { state: "error", retry: () => analytics.refetch(), period }; return { state: "loading" }; } const display = toIncomeDisplayModel(analytics.data); if (!display) return { state: "error", retry: () => analytics.refetch(), period }; return { state: "loaded", analytics: analytics.data, display, forecast: toForecast(events), form, onSubmit, onReset, }; }; function toForecast( events: ReturnType, ): IncomeForecast { if (events.isLoading) return { state: "loading" }; if (events.isError) return { state: "error", retry: () => events.refetch() }; const response = events.data; const upcoming = response?.events.filter( (event) => event.type === "coupon" || event.type === "dividend", ) ?? []; if (!response || !upcoming.length) return { state: "empty" }; return { state: "loaded", totals: response.incomeTotalsByCurrency, events: upcoming.slice(0, 3), }; } function defaultPeriod(): IncomePeriod { const today = moscowDate(new Date()); return { from: `${today.slice(0, 7)}-01`, to: today }; } function validPeriod(period: IncomePeriod): period is IncomePeriod { if (!isDate(period.from) || !isDate(period.to)) return false; 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, 10) === period.from && to.toISOString().slice(0, 10) === period.to && from <= to && to <= today && (to.valueOf() - from.valueOf()) / 86_400_000 + 1 <= 366 ); } function isDate(value: string): boolean { if (!datePattern.test(value)) return false; const parsed = new Date(`${value}T00:00:00Z`); return ( Number.isFinite(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value ); } function toIncomeDisplayModel( analytics: IncomeAnalytics, ): IncomeDisplayModel | null { const instruments = new Map< string, IncomeDisplayModel["instruments"][number] >(); for (const instrument of analytics.instruments) { if ( typeof instrument.key !== "string" || !instrument.key.trim() || instruments.has(instrument.key) ) return null; instruments.set(instrument.key, { ...instrument, entries: [] }); } 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.total.currency) return null; instrument.entries.push(entry); } for (const instrument of instruments.values()) { if (instrument.entries.length !== instrument.receiptCount) return null; } 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: [...instruments.values()], }; } 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", year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(value); const part = (type: string) => parts.find((item) => item.type === type)?.value; return `${part("year")}-${part("month")}-${part("day")}`; } export { type AccountIncomePageViewModel, isDate, toIncomeDisplayModel, useAccountIncomePageViewModel, };