Add useIsMobileViewport hook + Online-Board mobile time defaults per TZ 4.1.1-R4/R5
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user