feat: default journal period to last 7 days
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
+2
@@ -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"
|
||||||
|
|||||||
+7
-4
@@ -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",
|
||||||
|
|||||||
+2
@@ -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"
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user