diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx
index be038dce..116478c3 100644
--- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx
+++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx
@@ -7,8 +7,8 @@
* @vitest-environment jsdom
*/
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import {
@@ -19,6 +19,31 @@ import {
type BoardFilterSnapshot,
} from "@/shared/state/crossSectionNavigation.js";
+// ---------------------------------------------------------------------------
+// Hook mocks for geo + viewport — controlled per test
+// ---------------------------------------------------------------------------
+
+// Capture the latest onCity callback so individual tests can invoke it.
+let capturedOnCity: ((code: string) => void) | null = null;
+let capturedShouldApply: (() => boolean) | null = null;
+let geoMockEnabled = false;
+
+vi.mock("@/shared/hooks/useGeoCityDefault.js", () => ({
+ useGeoCityDefault: (opts: { shouldApply: () => boolean; onCity: (code: string) => void }) => {
+ capturedOnCity = opts.onCity;
+ capturedShouldApply = opts.shouldApply;
+ // If the test enabled geo, fire it synchronously so we don't need async.
+ if (geoMockEnabled && opts.shouldApply()) {
+ opts.onCity("MOW");
+ }
+ },
+}));
+
+let isMobileMockValue = false;
+vi.mock("@/shared/hooks/useIsMobileViewport.js", () => ({
+ useIsMobileViewport: () => isMobileMockValue,
+}));
+
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
@@ -242,3 +267,135 @@ describe("4.1.8: Online-Board hydrates from cross-section store on mount", () =>
expect(depInput.defaultValue).toBe("");
});
});
+
+// ---------------------------------------------------------------------------
+// TZ §4.1.1-R1/R3: first-entry geolocation auto-fill
+// ---------------------------------------------------------------------------
+
+describe("4.1.1-R1/R3: first-entry geolocation + defaults", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetCrossSectionStore();
+ geoMockEnabled = false;
+ isMobileMockValue = false;
+ capturedOnCity = null;
+ capturedShouldApply = null;
+ });
+
+ afterEach(() => {
+ geoMockEnabled = false;
+ isMobileMockValue = false;
+ });
+
+ it("4.1.1-R1: populates departure with geolocated city when no stored filter", () => {
+ geoMockEnabled = true; // mock will call onCity("MOW") synchronously on shouldApply()
+ render();
+ const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
+ expect(depInput.defaultValue).toBe("MOW");
+ });
+
+ it("4.1.1-R3: does not populate departure when geolocation is denied (geoMockEnabled=false)", () => {
+ // geoMockEnabled stays false → useGeoCityDefault mock never fires onCity
+ render();
+ const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
+ expect(depInput.defaultValue).toBe("");
+ });
+
+ it("4.1.1-R1: does not override departure when cross-section store already has board filter", () => {
+ setBoardFilter({
+ mode: "route",
+ departure: "LED",
+ arrival: "KGD",
+ date: "20260515",
+ timeFrom: "0000",
+ timeTo: "2400",
+ searchExecuted: false,
+ });
+ // shouldApply() checks getBoardFilter() — returns LED snapshot → returns false → onCity not called
+ geoMockEnabled = true;
+ render();
+ // Departure should be LED from the board snapshot, NOT overridden by geo.
+ const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
+ expect(depInput.defaultValue).toBe("LED");
+ });
+
+ it("4.1.1-R1: does not override departure when prefill already has departure set", () => {
+ // Schedule filter sets a departure so prefill.departure = "SVO"
+ setScheduleFilter({
+ mode: "route",
+ departure: "SVO",
+ arrival: "",
+ dateFrom: "20260515",
+ dateTo: "20260521",
+ timeFrom: "0000",
+ timeTo: "2400",
+ onlyDirect: false,
+ showReturn: false,
+ searchExecuted: true,
+ });
+ // shouldApply checks !getBoardFilter() (true) && !prefill.departure (false → SVO) → returns false
+ geoMockEnabled = true;
+ render();
+ const depInput = screen.getByTestId("route-departure-input") as HTMLInputElement;
+ expect(depInput.defaultValue).toBe("SVO");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TZ §4.1.1-R4: Online-Board Маршрут time default on desktop = 00:00-24:00
+// ---------------------------------------------------------------------------
+
+describe("4.1.1-R4: Online-Board time default desktop = 00:00-24:00", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetCrossSectionStore();
+ geoMockEnabled = false;
+ isMobileMockValue = false; // desktop
+ });
+
+ it("desktop viewport passes 00:00-24:00 as initial time range to filter", () => {
+ render();
+ // The time-selector shows "HH:MM — HH:MM" text; default is 00:00 — 24:00.
+ const timeSelector = screen.getByTestId("time-selector");
+ expect(timeSelector.textContent).toContain("00:00");
+ expect(timeSelector.textContent).toContain("24:00");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// TZ §4.1.1-R5: Online-Board Маршрут time default on mobile = -1h/+3h
+// ---------------------------------------------------------------------------
+
+describe("4.1.1-R5: Online-Board time default mobile = -1h/+3h", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetCrossSectionStore();
+ geoMockEnabled = false;
+ isMobileMockValue = true; // mobile
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ isMobileMockValue = false;
+ });
+
+ it("mobile viewport at 10:00 uses 09:00-13:00 time range", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 4, 15, 10, 0, 0));
+ render();
+ const timeSelector = screen.getByTestId("time-selector");
+ // -1h from 10:00 = 09:00, +3h from 10:00 = 13:00
+ expect(timeSelector.textContent).toContain("09:00");
+ expect(timeSelector.textContent).toContain("13:00");
+ });
+
+ it("mobile viewport at 00:30 clamps from-time to 00:00 (not negative)", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 4, 15, 0, 30, 0));
+ render();
+ const timeSelector = screen.getByTestId("time-selector");
+ // -1h from 00:30 would be -00:30 → clamps to 00:00; +3h = 03:30
+ expect(timeSelector.textContent).toContain("00:00");
+ expect(timeSelector.textContent).toContain("03:30");
+ });
+});
diff --git a/src/features/online-board/components/OnlineBoardStartPage.tsx b/src/features/online-board/components/OnlineBoardStartPage.tsx
index dd874d39..e1688b0d 100644
--- a/src/features/online-board/components/OnlineBoardStartPage.tsx
+++ b/src/features/online-board/components/OnlineBoardStartPage.tsx
@@ -12,7 +12,7 @@
* @module
*/
-import { type FC, useCallback, useState, useEffect } from "react";
+import { type FC, useCallback, useState, useRef } from "react";
import { useNavigate } from "@modern-js/runtime/router";
import { useLocale } from "@/i18n/useLocale.js";
import { useTranslation } from "@/i18n/provider.js";
@@ -37,6 +37,9 @@ import {
getCityCodeByAirportCode,
} from "@/shared/dictionaries/index.js";
import type { IDictionaries } from "@/shared/dictionaries/index.js";
+import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js";
+import { useIsMobileViewport } from "@/shared/hooks/useIsMobileViewport.js";
+import { getOnlineBoardDefaultTimeRange } from "../timeDefaults.js";
import "./OnlineBoardStartPage.scss";
/**
@@ -99,6 +102,12 @@ export const OnlineBoardStartPage: FC = () => {
const navigate = useNavigate();
const { locale, language } = useLocale();
const { dictionaries } = useDictionaries(language);
+ const isMobile = useIsMobileViewport();
+
+ // TZ §4.1.1-R4/R5: mobile-aware default time range, computed once on mount.
+ // On desktop/tablet: 00:00-24:00. On mobile: [-1h, +3h] from now.
+ // Stored in a ref so the value doesn't shift on re-renders.
+ const defaultTimeRange = useRef(getOnlineBoardDefaultTimeRange(isMobile)).current;
// Read-and-clear any prefill the previous page wrote. Stored in
// useState (with a one-shot initializer) so React strict mode's
@@ -135,11 +144,28 @@ export const OnlineBoardStartPage: FC = () => {
// useState initial values pick up the new prefill. Key bump does it.
const [filterKey, setFilterKey] = useState(0);
+ // TZ §4.1.1-R1: on first session entry with geo consent, auto-fill
+ // departure city with the user's nearest city when:
+ // - no stored board filter is present (fresh session), AND
+ // - the current prefill has no departure already (not populated by
+ // transient prefill, cross-section hydration, or user interaction).
+ useGeoCityDefault({
+ dictionaries,
+ shouldApply: () => !getBoardFilter() && !prefill.departure,
+ onCity: (cityCode) => {
+ setPrefill((prev) =>
+ prev.departure ? prev : { ...prev, departure: cityCode },
+ );
+ },
+ });
+
const filterInitialProps = {
...(prefill.tab ? { initialTab: prefill.tab } : {}),
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
+ initialTimeFrom: defaultTimeRange.timeFrom,
+ initialTimeTo: defaultTimeRange.timeTo,
};
const handlePopularRequestClick = useCallback(