fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)
This commit is contained in:
@@ -18,12 +18,21 @@ import type { IncomeAnalytics } from "@/entities/income";
|
|||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
import { defaultPeriod, formatDate } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
|
// Mantine calendar labels follow dayjs default (English) locale.
|
||||||
|
function dayLabel(date: string) {
|
||||||
|
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
|
||||||
|
.format(new Date(`${date}T12:00:00Z`));
|
||||||
|
|
||||||
|
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const accounts = [
|
const accounts = [
|
||||||
{ id: "a", name: "Основной" },
|
{ id: "a", name: "Основной" },
|
||||||
{ id: "b", name: "ИИС" },
|
{ id: "b", name: "ИИС" },
|
||||||
@@ -233,7 +242,7 @@ describe("All accounts routes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["operations", "events", "income"])(
|
it.each(["operations", "income"])(
|
||||||
"restores applied dates when switching accounts in %s",
|
"restores applied dates when switching accounts in %s",
|
||||||
async (section) => {
|
async (section) => {
|
||||||
const search = "?from=2026-08-01&to=2026-08-31";
|
const search = "?from=2026-08-01&to=2026-08-31";
|
||||||
@@ -261,6 +270,31 @@ describe("All accounts routes", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("restores applied dates when switching accounts in events", async () => {
|
||||||
|
// The events calendar blocks past dates, so pick within selectable dates.
|
||||||
|
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
|
||||||
|
renderPage(`/accounts/all/events${search}`);
|
||||||
|
const picker = await screen.findByLabelText("Период");
|
||||||
|
fireEvent.click(picker);
|
||||||
|
const [fromDate, toDate] = defaultPeriod();
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: dayLabel(fromDate) }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: dayLabel(toDate) }),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
`${formatDate(fromDate)} – ${formatDate(toDate)}`,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
|
||||||
|
`/accounts/b/events${search}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("groups the same security and links its accounts and overview sections", async () => {
|
it("groups the same security and links its accounts and overview sections", async () => {
|
||||||
renderPage("/accounts/all");
|
renderPage("/accounts/all");
|
||||||
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { defaultPeriod, formatDate } from "@/shared/lib";
|
import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -253,37 +253,58 @@ describe("Upcoming event date filters", () => {
|
|||||||
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
|
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
|
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
|
||||||
fireEvent.click(from);
|
|
||||||
|
// The calendar opens on the applied month, so reset to the default
|
||||||
|
// range first, then pick today and a future date.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||||
|
await waitFor(() => expect(screen.getByLabelText("URL")).toBeEmptyDOMElement());
|
||||||
|
const picker = await screen.findByLabelText("Период");
|
||||||
|
|
||||||
|
const [today] = defaultPeriod();
|
||||||
|
const [year = 1970, month = 0, day = 1] = today.split("-").map(Number);
|
||||||
|
|
||||||
|
const future = moscowDate(new Date(
|
||||||
|
Date.UTC(year, month - 1, day + 10, 12),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Mantine calendar labels follow dayjs default (English) locale.
|
||||||
|
const calendarLabel = (date: string) => {
|
||||||
|
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
|
||||||
|
.format(new Date(`${date}T12:00:00Z`));
|
||||||
|
|
||||||
|
const pattern = `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
|
||||||
|
|
||||||
|
// Dates are constructed above from validated defaultPeriod output.
|
||||||
|
// eslint-disable-next-line security/detect-non-literal-regexp -- see comment above
|
||||||
|
return new RegExp(pattern, "i");
|
||||||
|
};
|
||||||
|
|
||||||
|
fireEvent.click(picker);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: /10 February 2026/i }),
|
await screen.findByRole("button", { name: calendarLabel(today) }),
|
||||||
);
|
);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: /28 February 2026/i }),
|
await screen.findByRole("button", { name: calendarLabel(future) }),
|
||||||
);
|
);
|
||||||
expect(searches).toHaveLength(1);
|
// The reset itself fetched the default range; picking dates applies only on submit.
|
||||||
|
expect(searches).toEqual([
|
||||||
|
"?from=2026-02-01&to=2026-02-28",
|
||||||
|
`?from=${defaultFrom}&to=${defaultTo}`,
|
||||||
|
]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"),
|
expect(searches.at(-1)).toBe(`?from=${today}&to=${future}`),
|
||||||
);
|
);
|
||||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
"10.02.2026 – 28.02.2026",
|
`${formatDate(today)} – ${formatDate(future)}`,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
"01.02.2026 – 28.02.2026",
|
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
|
||||||
"?from=2026-02-01&to=2026-02-28",
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(searches.at(-1)).toBe(`?from=${defaultFrom}&to=${defaultTo}`),
|
|
||||||
);
|
|
||||||
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
|
||||||
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("За выбранный период событий не найдено"),
|
screen.getByText("За выбранный период событий не найдено"),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type AccountEventsPageViewModel
|
|||||||
canApply: boolean;
|
canApply: boolean;
|
||||||
draftPeriod: [null | string, null | string];
|
draftPeriod: [null | string, null | string];
|
||||||
events: BrokerageEvent[];
|
events: BrokerageEvent[];
|
||||||
maxDate: string;
|
minDate: string;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onPeriodChange: (value: [null | string, null | string]) => void;
|
onPeriodChange: (value: [null | string, null | string]) => void;
|
||||||
onPeriodPopoverOpen: () => void;
|
onPeriodPopoverOpen: () => void;
|
||||||
@@ -49,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
|
|
||||||
const [draftFrom, draftTo] = draftPeriod;
|
const [draftFrom, draftTo] = draftPeriod;
|
||||||
|
|
||||||
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
|
const [minDate, setMinDate] = useState(() => moscowDate(new Date()));
|
||||||
|
|
||||||
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
|
||||||
const [previousScope, setPreviousScope] = useState(scope);
|
const [previousScope, setPreviousScope] = useState(scope);
|
||||||
@@ -105,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
&& isValidPeriod(draftFrom, draftTo),
|
&& isValidPeriod(draftFrom, draftTo),
|
||||||
draftPeriod,
|
draftPeriod,
|
||||||
events: events.data.events,
|
events: events.data.events,
|
||||||
maxDate,
|
minDate,
|
||||||
onApply: () => {
|
onApply: () => {
|
||||||
if (!isValidPeriod(draftFrom, draftTo)) return;
|
if (!isValidPeriod(draftFrom, draftTo)) return;
|
||||||
setSearchParameters({
|
setSearchParameters({
|
||||||
@@ -116,7 +116,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
onPeriodChange: value => setDraftPeriod(
|
onPeriodChange: value => setDraftPeriod(
|
||||||
value[0] === null && value[1] === null ? defaultPeriod() : value,
|
value[0] === null && value[1] === null ? defaultPeriod() : value,
|
||||||
),
|
),
|
||||||
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
|
onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())),
|
||||||
onReset,
|
onReset,
|
||||||
partial: events.meta?.completeness === "incomplete",
|
partial: events.meta?.completeness === "incomplete",
|
||||||
source: events.meta,
|
source: events.meta,
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
clearable
|
clearable
|
||||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
label={t("ledger.period")}
|
label={t("ledger.period")}
|
||||||
maxDate={props.maxDate}
|
minDate={props.minDate}
|
||||||
onChange={props.onPeriodChange}
|
onChange={props.onPeriodChange}
|
||||||
popoverProps={{
|
popoverProps={{
|
||||||
middlewares: { shift: { crossAxis: true } },
|
middlewares: { shift: { crossAxis: true } },
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ describe("calendar dates", () => {
|
|||||||
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to the last 7 days including today", () => {
|
it("defaults to 7 calendar dates starting with today", () => {
|
||||||
const [from, to] = defaultPeriod();
|
const [from, to] = defaultPeriod();
|
||||||
|
|
||||||
expect(calendarDate.safeParse(from).success).toBe(true);
|
expect(calendarDate.safeParse(from).success).toBe(true);
|
||||||
expect(to).toBe(moscowDate(new Date()));
|
expect(from).toBe(moscowDate(new Date()));
|
||||||
expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6);
|
expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6);
|
||||||
expect(from <= to).toBe(true);
|
expect(from <= to).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ function isValidPeriod(from: null | string, to: null | string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DEFAULT_PERIOD_LAST_INDEX = 6;
|
||||||
const DEFAULT_PERIOD_DAYS = 6;
|
|
||||||
|
|
||||||
const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
|
const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
@@ -25,12 +24,17 @@ function compareDateDesc(a: null | string, b: null | string) {
|
|||||||
return (b ?? "").localeCompare(a ?? "");
|
return (b ?? "").localeCompare(a ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last 7 days including today. An empty period always means this range.
|
// 7 calendar dates starting with today (Moscow calendar arithmetic).
|
||||||
function defaultPeriod(): [string, string] {
|
function defaultPeriod(): [string, string] {
|
||||||
const to = new Date();
|
const from = moscowDate(new Date());
|
||||||
const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS);
|
|
||||||
|
|
||||||
return [moscowDate(from), moscowDate(to)];
|
const [year = 1970, month = 0, day = 1] = from.split("-").map(Number);
|
||||||
|
const noon = 12;
|
||||||
|
const lastDay = day + DEFAULT_PERIOD_LAST_INDEX;
|
||||||
|
const end = new Date(Date.UTC(year, month - 1, lastDay, noon));
|
||||||
|
const to = moscowDate(end);
|
||||||
|
|
||||||
|
return [from, to];
|
||||||
}
|
}
|
||||||
|
|
||||||
function moscowDate(value: Date): string {
|
function moscowDate(value: Date): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user