From 612ab6250979bf8d9b57f4feb6e1476ed3d759e4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 14 Oct 2026 21:22:09 +0300 Subject: [PATCH] fix(events): seven-date default period and picker from today (WealthLedger/frontend#63) --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 36 +++++++++++- .../AccountEventsPage.filters.test.tsx | 57 +++++++++++++------ .../AccountEventsPage/AccountEventsPage.vm.ts | 8 +-- .../AccountEventsPageLoaded.view.tsx | 2 +- src/shared/lib/calendar.test.ts | 4 +- src/shared/lib/calendar.ts | 16 ++++-- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 5965eab..a7dacf9 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -18,12 +18,21 @@ import type { IncomeAnalytics } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { AuthProvider } from "@/features/auth"; +import { defaultPeriod, formatDate } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; 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 = [ { id: "a", 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", async (section) => { 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 () => { renderPage("/accounts/all"); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index d9752ab..8bb0142 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -20,7 +20,7 @@ import { import { beforeEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; -import { defaultPeriod, formatDate } from "@/shared/lib"; +import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -253,37 +253,58 @@ describe("Upcoming event date filters", () => { expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); 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( - await screen.findByRole("button", { name: /10 February 2026/i }), + await screen.findByRole("button", { name: calendarLabel(today) }), ); 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: "Применить" })); 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( - "10.02.2026 – 28.02.2026", + `${formatDate(today)} – ${formatDate(future)}`, ); fireEvent.click(screen.getByRole("button", { name: "History back" })); await waitFor(() => expect(screen.getByLabelText("Период")).toHaveTextContent( - "01.02.2026 – 28.02.2026", + `${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`, ), ); - expect(screen.getByLabelText("URL")).toHaveTextContent( - "?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.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect( screen.getByText("За выбранный период событий не найдено"), diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 41be198..51ff5eb 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -21,7 +21,7 @@ type AccountEventsPageViewModel canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; - maxDate: string; + minDate: string; onApply: () => void; onPeriodChange: (value: [null | string, null | string]) => void; onPeriodPopoverOpen: () => void; @@ -49,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { 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 [previousScope, setPreviousScope] = useState(scope); @@ -105,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { && isValidPeriod(draftFrom, draftTo), draftPeriod, events: events.data.events, - maxDate, + minDate, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -116,7 +116,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodChange: value => setDraftPeriod( value[0] === null && value[1] === null ? defaultPeriod() : value, ), - onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), + onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onReset, partial: events.meta?.completeness === "incomplete", source: events.meta, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index ee44db1..e095af6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -72,7 +72,7 @@ const AccountEventsPageLoaded: FC = (props) => { clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - maxDate={props.maxDate} + minDate={props.minDate} onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts index 49ff20e..c0a017b 100644 --- a/src/shared/lib/calendar.test.ts +++ b/src/shared/lib/calendar.test.ts @@ -38,11 +38,11 @@ describe("calendar dates", () => { 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(); 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(from <= to).toBe(true); }); diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index 64b98e1..2b9681a 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,8 +9,7 @@ function isValidPeriod(from: null | string, to: null | string) { ); } -const DAY_MS = 86_400_000; -const DEFAULT_PERIOD_DAYS = 6; +const DEFAULT_PERIOD_LAST_INDEX = 6; const moscowFormatter = new Intl.DateTimeFormat("en-CA", { day: "2-digit", @@ -25,12 +24,17 @@ function compareDateDesc(a: null | string, b: null | string) { 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] { - const to = new Date(); - const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS); + const from = moscowDate(new Date()); - 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 { -- 2.54.0