Add useIsMobileViewport hook + Online-Board mobile time defaults per TZ 4.1.1-R4/R5

This commit is contained in:
2026-04-21 19:03:54 +03:00
parent bc0b10bd8e
commit 9aed10c281
4 changed files with 164 additions and 0 deletions
@@ -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",
});
});
});
+38
View File
@@ -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")}`;
}
@@ -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<typeof vi.fn>;
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);
});
});
+29
View File
@@ -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<boolean>(() => {
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;
}