From b31204c54311404c44fdcff8f33183dfe0e17c76 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 18:58:23 +0300 Subject: [PATCH] Add shared useGeoCityDefault hook (generalized from flights-map) --- src/shared/hooks/useGeoCityDefault.test.ts | 141 +++++++++++++++++++++ src/shared/hooks/useGeoCityDefault.ts | 53 ++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/shared/hooks/useGeoCityDefault.test.ts create mode 100644 src/shared/hooks/useGeoCityDefault.ts diff --git a/src/shared/hooks/useGeoCityDefault.test.ts b/src/shared/hooks/useGeoCityDefault.test.ts new file mode 100644 index 00000000..46f9fb14 --- /dev/null +++ b/src/shared/hooks/useGeoCityDefault.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useGeoCityDefault } from "./useGeoCityDefault.js"; +import type { IDictionaries } from "@/shared/dictionaries/index.js"; + +const mockDictionaries: IDictionaries = { + cities: [ + { + code: "MOW", + name: "Москва", + location: { lat: 55.7558, lon: 37.6173 }, + country_code: "RU", + countryName: "Russia", + has_afl_flights: true, + airports: [], + }, + { + code: "LED", + name: "Санкт-Петербург", + location: { lat: 59.9311, lon: 30.3609 }, + country_code: "RU", + countryName: "Russia", + has_afl_flights: true, + airports: [], + }, + ], + airports: [], + regions: [], + countries: [], + cityByCode: new Map([ + [ + "MOW", + { + code: "MOW", + name: "Москва", + location: { lat: 55.7558, lon: 37.6173 }, + country_code: "RU", + countryName: "Russia", + has_afl_flights: true, + airports: [], + }, + ], + [ + "LED", + { + code: "LED", + name: "Санкт-Петербург", + location: { lat: 59.9311, lon: 30.3609 }, + country_code: "RU", + countryName: "Russia", + has_afl_flights: true, + airports: [], + }, + ], + ]), + airportByCode: new Map(), + ruCityCodes: new Set(["MOW", "LED"]), + otherCityCodes: new Set(), +} as unknown as IDictionaries; + +describe("useGeoCityDefault", () => { + let getCurrentPositionMock: ReturnType; + + beforeEach(() => { + getCurrentPositionMock = vi.fn(); + Object.defineProperty(navigator, "geolocation", { + value: { getCurrentPosition: getCurrentPositionMock }, + configurable: true, + }); + }); + + afterEach(() => { vi.restoreAllMocks(); }); + + it("calls onCity with nearest city code when geolocation succeeds and shouldApply returns true", () => { + getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => { + onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition); + }); + const onCity = vi.fn(); + renderHook(() => + useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }), + ); + expect(onCity).toHaveBeenCalledWith("MOW"); + }); + + it("does not call onCity when shouldApply returns false", () => { + getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => { + onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition); + }); + const onCity = vi.fn(); + renderHook(() => + useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => false, onCity }), + ); + expect(onCity).not.toHaveBeenCalled(); + }); + + it("is silent when geolocation permission is denied", () => { + getCurrentPositionMock.mockImplementation((_s: unknown, onErr: (err: GeolocationPositionError) => void) => { + onErr({ code: 1, message: "denied" } as GeolocationPositionError); + }); + const onCity = vi.fn(); + renderHook(() => + useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }), + ); + expect(onCity).not.toHaveBeenCalled(); + }); + + it("is silent when navigator.geolocation is undefined", () => { + Object.defineProperty(navigator, "geolocation", { value: undefined, configurable: true }); + const onCity = vi.fn(); + renderHook(() => + useGeoCityDefault({ dictionaries: mockDictionaries, shouldApply: () => true, onCity }), + ); + expect(onCity).not.toHaveBeenCalled(); + }); + + it("fires only once per mount even if dictionaries change", () => { + getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => { + onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition); + }); + const onCity = vi.fn(); + const { rerender } = renderHook( + ({ dict }: { dict: IDictionaries }) => + useGeoCityDefault({ dictionaries: dict, shouldApply: () => true, onCity }), + { initialProps: { dict: mockDictionaries } }, + ); + rerender({ dict: { ...mockDictionaries } as IDictionaries }); + expect(onCity).toHaveBeenCalledTimes(1); + }); + + it("waits for dictionaries to be non-null before computing city", () => { + getCurrentPositionMock.mockImplementation((onSuccess: (pos: GeolocationPosition) => void) => { + onSuccess({ coords: { latitude: 55.75, longitude: 37.61 } } as GeolocationPosition); + }); + const onCity = vi.fn(); + renderHook(() => + useGeoCityDefault({ dictionaries: null, shouldApply: () => true, onCity }), + ); + expect(onCity).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/hooks/useGeoCityDefault.ts b/src/shared/hooks/useGeoCityDefault.ts new file mode 100644 index 00000000..e26d5195 --- /dev/null +++ b/src/shared/hooks/useGeoCityDefault.ts @@ -0,0 +1,53 @@ +/** + * One-shot geolocation → city-code hook. Matches Angular's + * `UserLocationService.location` semantics used by Online-Board, + * Schedule, and Flight-Map. Silent on permission denial / missing API. + * + * Fires at most once per mount. The `shouldApply` predicate is + * re-evaluated at callback time (after the geo permission resolves), + * letting the caller opt out if the user has already entered a value. + */ + +import { useEffect, useRef } from "react"; +import { + findCityByCoord, + type IDictionaries, +} from "@/shared/dictionaries/index.js"; + +export interface UseGeoCityDefaultOptions { + dictionaries: IDictionaries | null; + shouldApply: () => boolean; + onCity: (cityCode: string) => void; +} + +export function useGeoCityDefault(opts: UseGeoCityDefaultOptions): void { + const appliedRef = useRef(false); + const optsRef = useRef(opts); + optsRef.current = opts; + + useEffect(() => { + if (appliedRef.current) return; + if (typeof navigator === "undefined" || !navigator.geolocation) { + appliedRef.current = true; + return; + } + appliedRef.current = true; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const { dictionaries, shouldApply, onCity } = optsRef.current; + if (!dictionaries) return; + if (!shouldApply()) return; + const city = findCityByCoord( + dictionaries, + pos.coords.latitude, + pos.coords.longitude, + ); + if (!city) return; + onCity(city.code); + }, + () => { /* silent */ }, + { enableHighAccuracy: false, timeout: 5000 }, + ); + }, []); +}