From 0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 6 Oct 2026 22:05:41 +0300 Subject: [PATCH] feat: default journal period to last 7 days --- .../AccountEventsPage.filters.test.tsx | 41 ++++++++++++------- .../AccountEventsPage.test.tsx | 11 ++++- .../AccountEventsPage/AccountEventsPage.vm.ts | 20 +++++++-- .../AccountEventsPageLoaded.view.tsx | 2 + .../AccountOperationsPage.test.tsx | 11 +++-- .../AccountOperationsPage.vm.ts | 20 +++++++-- .../AccountOperationsPageLoaded.view.tsx | 2 + src/shared/lib/calendar.test.ts | 16 +++++++- src/shared/lib/calendar.ts | 13 +++++- src/shared/lib/index.ts | 2 +- src/shared/lib/useUrlPeriod.ts | 17 ++++++-- 11 files changed, 121 insertions(+), 34 deletions(-) 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 33015bf..f2694f5 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -20,6 +20,7 @@ import { import { beforeEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { defaultPeriod, formatDate } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -157,6 +158,7 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "normalizes invalid links and history while preserving one-sided future dates for %s", async (accountId) => { + const [defaultFrom, defaultTo] = defaultPeriod(); const searches: string[] = []; server.use( http.get( @@ -195,20 +197,24 @@ describe("Upcoming event date filters", () => { ), ); expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099"); - expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]); + expect(searches[0]).toBe(""); + expect(searches).toContain(`?from=${defaultFrom}&to=${defaultTo}`); + expect(searches).toContain("?from=2099-02-01"); + expect(searches).toContain("?to=2099-02-28"); }, ); it.each(["account-1", "all"])( "lets users reset filters after a request failure for %s", async (accountId) => { + const [defaultFrom] = defaultPeriod(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => - new URL(request.url).searchParams.has("from") - ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) - : HttpResponse.json(resource({ events: [] })), + new URL(request.url).searchParams.get("from") === defaultFrom + ? HttpResponse.json(resource({ events: [] })) + : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), ), ); renderEvents(accountId, ["?from=2099-02-01"]); @@ -222,6 +228,7 @@ describe("Upcoming event date filters", () => { it.each(["account-1", "all"])( "applies dates only on submit, restores URL dates, and resets from empty results for %s", async (accountId) => { + const [defaultFrom, defaultTo] = defaultPeriod(); const searches: string[] = []; server.use( http.get( @@ -240,38 +247,42 @@ describe("Upcoming event date filters", () => { }, ), ); - renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]); + renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); const from = await screen.findByLabelText("Период"); - expect(from).toHaveTextContent("01.02.2099 – 28.02.2099"); + expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); - expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]); + expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); fireEvent.click(from); fireEvent.click( - await screen.findByRole("button", { name: /10 February 2099/i }), + await screen.findByRole("button", { name: /10 February 2026/i }), ); fireEvent.click( - await screen.findByRole("button", { name: /28 February 2099/i }), + await screen.findByRole("button", { name: /28 February 2026/i }), ); expect(searches).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => - expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"), + expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"), ); expect(await screen.findByLabelText("Период")).toHaveTextContent( - "10.02.2099 – 28.02.2099", + "10.02.2026 – 28.02.2026", ); fireEvent.click(screen.getByRole("button", { name: "History back" })); await waitFor(() => expect(screen.getByLabelText("Период")).toHaveTextContent( - "01.02.2099 – 28.02.2099", + "01.02.2026 – 28.02.2026", ), ); expect(screen.getByLabelText("URL")).toHaveTextContent( - "?from=2099-02-01&to=2099-02-28", + "?from=2026-02-01&to=2026-02-28", ); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); - await waitFor(() => expect(searches.at(-1)).toBe("")); - expect(await screen.findByLabelText("Период")).toHaveTextContent(""); + 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.getByText("За выбранный период событий не найдено"), diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 4bfa29e..bde07ed 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as BrokerageEvents from "@/entities/brokerage-event"; import { useBrokerageEvents } from "@/entities/brokerage-event"; +import { defaultPeriod } from "@/shared/lib"; import { AccountEventsPage } from "./AccountEventsPage"; @@ -71,7 +72,10 @@ describe("AccountEventsPage", () => { , ); - expect(query).toHaveBeenCalledWith("selected-account", {}); + expect(query).toHaveBeenCalledWith("selected-account", { + from: defaultPeriod()[0], + to: defaultPeriod()[1], + }); }); it("keeps the loading fallback when no result or account is available", () => { @@ -88,7 +92,10 @@ describe("AccountEventsPage", () => { } as never, ); renderPage(); - expect(query).toHaveBeenCalledWith("", {}); + expect(query).toHaveBeenCalledWith("", { + from: defaultPeriod()[0], + to: defaultPeriod()[1], + }); expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( "aria-busy", "true", diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index ce2a856..f947b85 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -6,15 +6,23 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; -import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib"; +import { + defaultPeriod, + isValidPeriod, + moscowDate, + useInitialAvailability, + useUrlPeriod, +} from "@/shared/lib"; type AccountEventsPageViewModel = | { canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; + maxDate: string; onApply: () => void; onPeriodChange: (value: [null | string, null | string]) => void; + onPeriodPopoverOpen: () => void; onReset: () => void; partial?: boolean; state: "loaded"; @@ -38,6 +46,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { const [draftFrom, draftTo] = draftPeriod; + const [maxDate, setMaxDate] = useState(() => moscowDate(new Date())); + const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const [previousScope, setPreviousScope] = useState(scope); @@ -72,7 +82,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { return { state: "loading" }; const onReset = () => { - setDraftPeriod([null, null]); + setDraftPeriod([appliedFrom, appliedTo]); setSearchParameters({}); }; @@ -92,6 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { && isValidPeriod(draftFrom, draftTo), draftPeriod, events: events.data.events, + maxDate, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -99,7 +110,10 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { ...(draftTo && { to: draftTo }), }); }, - onPeriodChange: setDraftPeriod, + onPeriodChange: value => setDraftPeriod( + value[0] === null && value[1] === null ? defaultPeriod() : value, + ), + onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onReset, partial: events.meta?.availability !== "complete", state: "loaded", 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 dd0743c..1b6b2bc 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -65,9 +65,11 @@ const AccountEventsPageLoaded: FC = (props) => { clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} + maxDate={props.maxDate} onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, + onOpen: props.onPeriodPopoverOpen, returnFocus: true, }} type="range" diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 6a0f9fe..7f443e9 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -18,6 +18,7 @@ import { LedgerEntryRow, } from "@/entities/broker-operation"; import { configureAuthClient } from "@/shared/api"; +import { defaultPeriod } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -312,18 +313,20 @@ describe("AccountOperationsPage", () => { "allows resetting filters after a request failure for %s", async (accountId) => { configureTestAuth(); + const [defaultFrom] = defaultPeriod(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => - new URL(request.url).searchParams.has("from") - ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) - : HttpResponse.json( + new URL(request.url).searchParams.get("from") + === `${defaultFrom}T00:00:00.000Z` + ? HttpResponse.json( resource( { hasNext: false, items: [], nextCursor: null }, ), - ), + ) + : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), ), ); render( diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index db29494..7449ac9 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -11,7 +11,13 @@ import { } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib"; +import { + defaultPeriod, + isValidPeriod, + moscowDate, + useInitialAvailability, + useUrlPeriod, +} from "@/shared/lib"; const operationCategorySet = new Set(operationCategories); @@ -24,10 +30,12 @@ type AccountOperationsPageViewModel hasPrevious: boolean; historyLoading?: boolean; items: BrokerageOperation[]; + maxDate: string; onApply: () => void; onCategoriesChange: (value: string[]) => void; onNext: () => void; onPeriodChange: (value: [null | string, null | string]) => void; + onPeriodPopoverOpen: () => void; onPrevious: () => void; onReset: () => void; partial?: boolean; @@ -61,6 +69,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const [draftCategories, setDraftCategories] = useState(appliedCategories); + const [maxDate, setMaxDate] = useState(() => moscowDate(new Date())); + const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), @@ -103,7 +113,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { }).queryKey; const onReset = () => { - setDraftPeriod([null, null]); + setDraftPeriod([appliedFrom, appliedTo]); setDraftCategories([]); setSearchParameters({}); history.reset(); @@ -143,6 +153,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { operations.meta?.coverage?.historyState === "loading" || operations.meta?.coverage?.historyState === "partial", items: operations.data.items, + maxDate, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -160,7 +171,10 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { ), ), onNext: history.onNext, - onPeriodChange: setDraftPeriod, + onPeriodChange: value => setDraftPeriod( + value[0] === null && value[1] === null ? defaultPeriod() : value, + ), + onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPrevious: history.onPrevious, onReset, partial: operations.meta?.availability !== "complete", diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 18a69e7..208ff5f 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -72,9 +72,11 @@ const AccountOperationsPageLoaded: FC = ( clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} + maxDate={props.maxDate} onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, + onOpen: props.onPeriodPopoverOpen, returnFocus: true, }} type="range" diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts index d27959b..49ff20e 100644 --- a/src/shared/lib/calendar.test.ts +++ b/src/shared/lib/calendar.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; +import { + compareDateDesc, + defaultPeriod, + isValidPeriod, + moscowDate, +} from "./calendar"; import { calendarDate } from "./calendar.schema"; describe("calendar dates", () => { @@ -33,6 +38,15 @@ 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", () => { + const [from, to] = defaultPeriod(); + + expect(calendarDate.safeParse(from).success).toBe(true); + expect(to).toBe(moscowDate(new Date())); + expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6); + expect(from <= to).toBe(true); + }); + it("compares dates newest first and keeps dateless rows last", () => { const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"]; diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index c3697b3..64b98e1 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,6 +9,9 @@ function isValidPeriod(from: null | string, to: null | string) { ); } +const DAY_MS = 86_400_000; +const DEFAULT_PERIOD_DAYS = 6; + const moscowFormatter = new Intl.DateTimeFormat("en-CA", { day: "2-digit", month: "2-digit", @@ -22,6 +25,14 @@ function compareDateDesc(a: null | string, b: null | string) { return (b ?? "").localeCompare(a ?? ""); } +// Last 7 days including today. An empty period always means this range. +function defaultPeriod(): [string, string] { + const to = new Date(); + const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS); + + return [moscowDate(from), moscowDate(to)]; +} + function moscowDate(value: Date): string { const parts = moscowFormatter.formatToParts(value); @@ -31,4 +42,4 @@ function moscowDate(value: Date): string { return `${part("year")}-${part("month")}-${part("day")}`; } -export { compareDateDesc, isValidPeriod, moscowDate }; +export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 9b45661..fa0ac06 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,4 @@ -export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; +export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar"; export { calendarDate } from "./calendar.schema"; export { formatChartValue, diff --git a/src/shared/lib/useUrlPeriod.ts b/src/shared/lib/useUrlPeriod.ts index 0129edd..d3a19af 100644 --- a/src/shared/lib/useUrlPeriod.ts +++ b/src/shared/lib/useUrlPeriod.ts @@ -1,13 +1,22 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useSearchParams } from "react-router"; -import { isValidPeriod } from "./calendar"; +import { defaultPeriod, isValidPeriod } from "./calendar"; function useUrlPeriod() { const [parameters, setParameters] = useSearchParams(); const from = parameters.get("from"); const to = parameters.get("to"); const isValid = isValidPeriod(from, to); + const hasPeriod = from !== null || to !== null; + const [[defaultFrom, defaultTo]] = useState<[string, string]>(defaultPeriod); + + const resolve = (value: null | string, fallback: string) => { + if (!isValid) return null; + + return hasPeriod ? value : fallback; + }; + useEffect(() => { if (isValid || (!parameters.has("from") && !parameters.has("to"))) return; const normalized = new URLSearchParams(parameters); @@ -17,10 +26,10 @@ function useUrlPeriod() { }, [isValid, parameters, setParameters]); return { - from: isValid ? from : null, + from: resolve(from, defaultFrom), parameters, setParameters, - to: isValid ? to : null, + to: resolve(to, defaultTo), }; }