feat(income): add account income analytics
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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 AccountIncomePageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||||
| {
|
||||
state: "loaded";
|
||||
analytics: IncomeAnalytics;
|
||||
forecast: IncomeForecast;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
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<IncomeForm>({
|
||||
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" };
|
||||
}
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
forecast: toForecast(events),
|
||||
form,
|
||||
onSubmit,
|
||||
onReset,
|
||||
};
|
||||
};
|
||||
|
||||
function toForecast(
|
||||
events: ReturnType<typeof useBrokerageEvents>,
|
||||
): 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 {
|
||||
return (
|
||||
datePattern.test(value) &&
|
||||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
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, useAccountIncomePageViewModel };
|
||||
Reference in New Issue
Block a user