feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-06 22:05:41 +03:00
parent c25f6bd85b
commit 0ea8d7b62d
11 changed files with 121 additions and 34 deletions
@@ -20,6 +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 { 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";
@@ -157,6 +158,7 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s", "normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
http.get( http.get(
@@ -195,20 +197,24 @@ describe("Upcoming event date filters", () => {
), ),
); );
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099"); 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"])( it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s", "lets users reset filters after a request failure for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom] = defaultPeriod();
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.has("from") new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) ? HttpResponse.json(resource({ events: [] }))
: HttpResponse.json(resource({ events: [] })), : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
renderEvents(accountId, ["?from=2099-02-01"]); renderEvents(accountId, ["?from=2099-02-01"]);
@@ -222,6 +228,7 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])( it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s", "applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => { async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = []; const searches: string[] = [];
server.use( server.use(
http.get( 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("Период"); 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(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(from);
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: /10 February 2099/i }), await screen.findByRole("button", { name: /10 February 2026/i }),
); );
fireEvent.click( fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }), await screen.findByRole("button", { name: /28 February 2026/i }),
); );
expect(searches).toHaveLength(1); expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" })); fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() => 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( 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" })); fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent( expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.02.2099", "01.02.2026 – 28.02.2026",
), ),
); );
expect(screen.getByLabelText("URL")).toHaveTextContent( 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: "Сбросить" })); fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe("")); await waitFor(() =>
expect(await screen.findByLabelText("Период")).toHaveTextContent(""); 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("За выбранный период событий не найдено"),
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as BrokerageEvents from "@/entities/brokerage-event"; import type * as BrokerageEvents from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event"; import { useBrokerageEvents } from "@/entities/brokerage-event";
import { defaultPeriod } from "@/shared/lib";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
@@ -71,7 +72,10 @@ describe("AccountEventsPage", () => {
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
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", () => { it("keeps the loading fallback when no result or account is available", () => {
@@ -88,7 +92,10 @@ describe("AccountEventsPage", () => {
} as never, } as never,
); );
renderPage(); renderPage();
expect(query).toHaveBeenCalledWith("", {}); expect(query).toHaveBeenCalledWith("", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy", "aria-busy",
"true", "true",
@@ -6,15 +6,23 @@ import {
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib"; import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
maxDate: string;
onApply: () => void; onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
state: "loaded"; state: "loaded";
@@ -38,6 +46,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod; const [draftFrom, draftTo] = draftPeriod;
const [maxDate, setMaxDate] = 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);
@@ -72,7 +82,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
return { state: "loading" }; return { state: "loading" };
const onReset = () => { const onReset = () => {
setDraftPeriod([null, null]); setDraftPeriod([appliedFrom, appliedTo]);
setSearchParameters({}); setSearchParameters({});
}; };
@@ -92,6 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
draftPeriod, draftPeriod,
events: events.data.events, events: events.data.events,
maxDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -99,7 +110,10 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(draftTo && { to: draftTo }), ...(draftTo && { to: draftTo }),
}); });
}, },
onPeriodChange: setDraftPeriod, onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.availability !== "complete", partial: events.meta?.availability !== "complete",
state: "loaded", state: "loaded",
@@ -65,9 +65,11 @@ 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}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true, returnFocus: true,
}} }}
type="range" type="range"
@@ -18,6 +18,7 @@ import {
LedgerEntryRow, LedgerEntryRow,
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { defaultPeriod } 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";
@@ -312,18 +313,20 @@ describe("AccountOperationsPage", () => {
"allows resetting filters after a request failure for %s", "allows resetting filters after a request failure for %s",
async (accountId) => { async (accountId) => {
configureTestAuth(); configureTestAuth();
const [defaultFrom] = defaultPeriod();
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.has("from") new URL(request.url).searchParams.get("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) === `${defaultFrom}T00:00:00.000Z`
: HttpResponse.json( ? HttpResponse.json(
resource( resource(
{ hasNext: false, items: [], nextCursor: null }, { hasNext: false, items: [], nextCursor: null },
), ),
), )
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
render( render(
@@ -11,7 +11,13 @@ import {
} from "@/entities/broker-operation"; } from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection"; import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; 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<string>(operationCategories); const operationCategorySet = new Set<string>(operationCategories);
@@ -24,10 +30,12 @@ type AccountOperationsPageViewModel
hasPrevious: boolean; hasPrevious: boolean;
historyLoading?: boolean; historyLoading?: boolean;
items: BrokerageOperation[]; items: BrokerageOperation[];
maxDate: string;
onApply: () => void; onApply: () => void;
onCategoriesChange: (value: string[]) => void; onCategoriesChange: (value: string[]) => void;
onNext: () => void; onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void; onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
@@ -61,6 +69,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories] const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories); = useState<OperationCategory[]>(appliedCategories);
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const firstPageQuery = { const firstPageQuery = {
from: dateStart(appliedFrom), from: dateStart(appliedFrom),
to: dateEnd(appliedTo), to: dateEnd(appliedTo),
@@ -103,7 +113,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
}).queryKey; }).queryKey;
const onReset = () => { const onReset = () => {
setDraftPeriod([null, null]); setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories([]); setDraftCategories([]);
setSearchParameters({}); setSearchParameters({});
history.reset(); history.reset();
@@ -143,6 +153,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
operations.meta?.coverage?.historyState === "loading" operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial", || operations.meta?.coverage?.historyState === "partial",
items: operations.data.items, items: operations.data.items,
maxDate,
onApply: () => { onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return; if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({ setSearchParameters({
@@ -160,7 +171,10 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
), ),
), ),
onNext: history.onNext, onNext: history.onNext,
onPeriodChange: setDraftPeriod, onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.availability !== "complete", partial: operations.meta?.availability !== "complete",
@@ -72,9 +72,11 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
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}
onChange={props.onPeriodChange} onChange={props.onPeriodChange}
popoverProps={{ popoverProps={{
middlewares: { shift: { crossAxis: true } }, middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true, returnFocus: true,
}} }}
type="range" type="range"
+15 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; import {
compareDateDesc,
defaultPeriod,
isValidPeriod,
moscowDate,
} from "./calendar";
import { calendarDate } from "./calendar.schema"; import { calendarDate } from "./calendar.schema";
describe("calendar dates", () => { describe("calendar dates", () => {
@@ -33,6 +38,15 @@ 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", () => {
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", () => { it("compares dates newest first and keeps dateless rows last", () => {
const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"]; const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"];
+12 -1
View File
@@ -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", { const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
@@ -22,6 +25,14 @@ 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.
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 { function moscowDate(value: Date): string {
const parts = moscowFormatter.formatToParts(value); const parts = moscowFormatter.formatToParts(value);
@@ -31,4 +42,4 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`; return `${part("year")}-${part("month")}-${part("day")}`;
} }
export { compareDateDesc, isValidPeriod, moscowDate }; export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate };
+1 -1
View File
@@ -1,4 +1,4 @@
export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar"; export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema"; export { calendarDate } from "./calendar.schema";
export { export {
formatChartValue, formatChartValue,
+13 -4
View File
@@ -1,13 +1,22 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { isValidPeriod } from "./calendar"; import { defaultPeriod, isValidPeriod } from "./calendar";
function useUrlPeriod() { function useUrlPeriod() {
const [parameters, setParameters] = useSearchParams(); const [parameters, setParameters] = useSearchParams();
const from = parameters.get("from"); const from = parameters.get("from");
const to = parameters.get("to"); const to = parameters.get("to");
const isValid = isValidPeriod(from, 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(() => { useEffect(() => {
if (isValid || (!parameters.has("from") && !parameters.has("to"))) return; if (isValid || (!parameters.has("from") && !parameters.has("to"))) return;
const normalized = new URLSearchParams(parameters); const normalized = new URLSearchParams(parameters);
@@ -17,10 +26,10 @@ function useUrlPeriod() {
}, [isValid, parameters, setParameters]); }, [isValid, parameters, setParameters]);
return { return {
from: isValid ? from : null, from: resolve(from, defaultFrom),
parameters, parameters,
setParameters, setParameters,
to: isValid ? to : null, to: resolve(to, defaultTo),
}; };
} }