diff --git a/src/features/online-board/timeDefaults.test.ts b/src/features/online-board/timeDefaults.test.ts new file mode 100644 index 00000000..536925a5 --- /dev/null +++ b/src/features/online-board/timeDefaults.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { getOnlineBoardDefaultTimeRange } from "./timeDefaults.js"; + +describe("4.1.1-R4 + R5: Online-Board default time range", () => { + it("desktop/tablet default = 00:00-24:00 regardless of time", () => { + expect(getOnlineBoardDefaultTimeRange(false, new Date(2026, 4, 15, 9, 0))).toEqual({ + timeFrom: "0000", + timeTo: "2400", + }); + expect(getOnlineBoardDefaultTimeRange(false, new Date(2026, 4, 15, 22, 30))).toEqual({ + timeFrom: "0000", + timeTo: "2400", + }); + }); + + it("mobile default = -1h/+3h from user's current hour", () => { + expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 9, 0))).toEqual({ + timeFrom: "0800", + timeTo: "1200", + }); + }); + + it("mobile default clamps to 00:00 when current hour < 1", () => { + expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 0, 30))).toEqual({ + timeFrom: "0000", + timeTo: "0330", + }); + }); + + it("mobile default clamps to 24:00 when current hour > 21", () => { + expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 22, 15))).toEqual({ + timeFrom: "2115", + timeTo: "2400", + }); + }); + + it("mobile default preserves minutes in HHmm format", () => { + expect(getOnlineBoardDefaultTimeRange(true, new Date(2026, 4, 15, 14, 37))).toEqual({ + timeFrom: "1337", + timeTo: "1737", + }); + }); +}); diff --git a/src/features/online-board/timeDefaults.ts b/src/features/online-board/timeDefaults.ts new file mode 100644 index 00000000..810b368a --- /dev/null +++ b/src/features/online-board/timeDefaults.ts @@ -0,0 +1,38 @@ +/** + * Online-Board `Время рейса` default time range per TZ §4.1.1-R4 and R5. + * + * Desktop/tablet: always `00:00-24:00`. + * Mobile: [-1h, +3h] window centered on the user's current local time, + * clamped to [00:00, 24:00]. + * + * Output uses `HHmm` strings (e.g. "0800" = 08:00). `2400` represents + * end-of-day inclusive, matching existing URL encoding. + */ + +export interface TimeRange { + timeFrom: string; + timeTo: string; +} + +export function getOnlineBoardDefaultTimeRange( + isMobile: boolean, + now: Date = new Date(), +): TimeRange { + if (!isMobile) { + return { timeFrom: "0000", timeTo: "2400" }; + } + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const fromMinutes = Math.max(0, nowMinutes - 60); + const toMinutes = Math.min(24 * 60, nowMinutes + 3 * 60); + return { + timeFrom: minutesToHHmm(fromMinutes), + timeTo: minutesToHHmm(toMinutes), + }; +} + +function minutesToHHmm(total: number): string { + if (total === 24 * 60) return "2400"; + const h = Math.floor(total / 60); + const m = total % 60; + return `${String(h).padStart(2, "0")}${String(m).padStart(2, "0")}`; +} diff --git a/src/shared/hooks/useIsMobileViewport.test.ts b/src/shared/hooks/useIsMobileViewport.test.ts new file mode 100644 index 00000000..ac5c484b --- /dev/null +++ b/src/shared/hooks/useIsMobileViewport.test.ts @@ -0,0 +1,54 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useIsMobileViewport } from "./useIsMobileViewport.js"; + +describe("useIsMobileViewport", () => { + let matchMediaMock: ReturnType; + let listeners: ((e: { matches: boolean }) => void)[]; + let currentMatches = false; + + beforeEach(() => { + listeners = []; + currentMatches = false; + matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: currentMatches, + media: query, + addEventListener: (_type: string, listener: (e: { matches: boolean }) => void) => { + listeners.push(listener); + }, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); + Object.defineProperty(window, "matchMedia", { value: matchMediaMock, configurable: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns false when viewport is wider than mobile breakpoint", () => { + currentMatches = false; + const { result } = renderHook(() => useIsMobileViewport()); + expect(result.current).toBe(false); + }); + + it("returns true when viewport is narrower than mobile breakpoint", () => { + currentMatches = true; + const { result } = renderHook(() => useIsMobileViewport()); + expect(result.current).toBe(true); + }); + + it("updates when media-query match changes", () => { + currentMatches = false; + const { result } = renderHook(() => useIsMobileViewport()); + expect(result.current).toBe(false); + act(() => { + for (const l of listeners) l({ matches: true }); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/src/shared/hooks/useIsMobileViewport.ts b/src/shared/hooks/useIsMobileViewport.ts new file mode 100644 index 00000000..fc1b02fa --- /dev/null +++ b/src/shared/hooks/useIsMobileViewport.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +/** Mobile breakpoint — matches the project's `$media-breakpoint-mobile` token (640px). */ +const MOBILE_BREAKPOINT_PX = 640; + +/** + * Returns `true` when the viewport is ≤ the mobile breakpoint. Used to + * branch TZ-prescribed mobile-only behaviors (e.g. Online-Board's + * `Время рейса` default = -1h/+3h per §4.1.1-R5). + * + * SSR: returns `false` initially, then updates on first client render. + */ +export function useIsMobileViewport(): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`).matches; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`); + const listener = (e: MediaQueryListEvent | { matches: boolean }) => setIsMobile(e.matches); + setIsMobile(mql.matches); + mql.addEventListener("change", listener as (e: MediaQueryListEvent) => void); + return () => mql.removeEventListener("change", listener as (e: MediaQueryListEvent) => void); + }, []); + + return isMobile; +}