diff --git a/docs/superpowers/plans/2026-04-17-flights-map-c5-calendar-geolocation.md b/docs/superpowers/plans/2026-04-17-flights-map-c5-calendar-geolocation.md new file mode 100644 index 00000000..2e90423e --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-flights-map-c5-calendar-geolocation.md @@ -0,0 +1,931 @@ +# Flights Map C.5 (Calendar + Geolocation) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire `availableDays` into the filter's PrimeReact `` with `minDate`/`maxDate`/`disabledDates` + Angular's snap-to-nearest-enabled behavior; add a `useGeolocationDefault` hook that pre-fills `filterState.departure` from browser geolocation when nothing is typed. + +**Architecture:** New pure `calendarRange.ts` with four date helpers (today, minDate, maxDate, buildDisabledDates, findNextEnabledDate). New `useGeolocationDefault` hook using `navigator.geolocation.getCurrentPosition` + `findCityByCoord` from dictionaries. `FlightsMapFilter` computes `disabledDates` from `availableDays`, passes min/max, runs a snap effect. `FlightsMapStartPage` calls the geolocation hook once. + +**Tech Stack:** TypeScript, React 18, PrimeReact ``, vitest, jsdom, @testing-library/react. + +**Related spec:** `docs/superpowers/specs/2026-04-17-flights-map-c5-calendar-geolocation-design.md` + +--- + +## File Structure + +**New:** +- `src/features/flights-map/calendarRange.ts` +- `src/features/flights-map/calendarRange.test.ts` +- `src/features/flights-map/hooks/useGeolocationDefault.ts` +- `src/features/flights-map/hooks/useGeolocationDefault.test.tsx` +- `src/features/flights-map/components/FlightsMapFilter.test.tsx` + +**Modified:** +- `src/features/flights-map/components/FlightsMapFilter.tsx` — consume `availableDays` via new helpers + snap effect. +- `src/features/flights-map/components/FlightsMapStartPage.tsx` — one line: call `useGeolocationDefault(...)`. + +**Untouched:** +- `useFlightsMapCalendar` (already returns `availableDays`). +- `MapCanvas`, dictionaries, all prior C-gap modules. + +--- + +## Task 1: `calendarRange` pure helpers + +**Files:** +- Create: `src/features/flights-map/calendarRange.ts` +- Create: `src/features/flights-map/calendarRange.test.ts` + +- [ ] **Step 1.1: Write failing tests** + +Create `src/features/flights-map/calendarRange.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + getMinDate, + getMaxDate, + buildDisabledDates, + findNextEnabledDate, +} from "./calendarRange.js"; + +function yyyymmdd(d: Date): string { + const y = d.getFullYear().toString(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}${m}${day}`; +} + +function addDays(d: Date, n: number): Date { + const x = new Date(d); + x.setDate(x.getDate() + n); + x.setHours(0, 0, 0, 0); + return x; +} + +describe("getMinDate / getMaxDate", () => { + it("minDate is today - 1 day, time zero'd", () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const expected = addDays(today, -1); + const min = getMinDate(); + expect(min.getTime()).toBe(expected.getTime()); + expect(min.getHours()).toBe(0); + expect(min.getMinutes()).toBe(0); + expect(min.getSeconds()).toBe(0); + }); + + it("maxDate is today + 6 months, time zero'd", () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const expected = new Date(today); + expected.setMonth(expected.getMonth() + 6); + const max = getMaxDate(); + expect(max.getTime()).toBe(expected.getTime()); + }); +}); + +describe("buildDisabledDates", () => { + const min = new Date(2026, 0, 1); + const max = new Date(2026, 0, 5); + + it("returns every day in the window when availableDays is empty", () => { + const disabled = buildDisabledDates(min, max, []); + expect(disabled.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5]); + }); + + it("returns [] when all dates in window are available", () => { + const available = ["20260101", "20260102", "20260103", "20260104", "20260105"]; + expect(buildDisabledDates(min, max, available)).toEqual([]); + }); + + it("returns the exact complement for partial availability", () => { + const available = ["20260102", "20260104"]; + const disabled = buildDisabledDates(min, max, available); + expect(disabled.map((d) => d.getDate())).toEqual([1, 3, 5]); + }); + + it("ignores availableDays entries outside the window", () => { + const available = ["20251231", "20260601", "20260103"]; + const disabled = buildDisabledDates(min, max, available); + expect(disabled.map((d) => d.getDate())).toEqual([1, 2, 4, 5]); + }); +}); + +describe("findNextEnabledDate", () => { + const min = new Date(2026, 0, 1); + const max = new Date(2026, 0, 5); + + it("returns current when current is enabled", () => { + const current = new Date(2026, 0, 3); + const disabled = [new Date(2026, 0, 1), new Date(2026, 0, 2)]; + const result = findNextEnabledDate(current, disabled, min, max); + expect(result?.getDate()).toBe(3); + }); + + it("advances to the next enabled date when current is disabled", () => { + const current = new Date(2026, 0, 2); + const disabled = [new Date(2026, 0, 1), new Date(2026, 0, 2), new Date(2026, 0, 3)]; + const result = findNextEnabledDate(current, disabled, min, max); + expect(result?.getDate()).toBe(4); + }); + + it("returns null when every date in window is disabled", () => { + const current = new Date(2026, 0, 3); + const disabled = [ + new Date(2026, 0, 1), + new Date(2026, 0, 2), + new Date(2026, 0, 3), + new Date(2026, 0, 4), + new Date(2026, 0, 5), + ]; + expect(findNextEnabledDate(current, disabled, min, max)).toBeNull(); + }); + + it("starts at minDate when current < minDate", () => { + const current = new Date(2025, 11, 25); + const disabled: Date[] = []; + const result = findNextEnabledDate(current, disabled, min, max); + expect(result?.getDate()).toBe(1); + }); + + it("returns null when current > maxDate", () => { + const current = new Date(2026, 1, 1); + const disabled: Date[] = []; + expect(findNextEnabledDate(current, disabled, min, max)).toBeNull(); + }); +}); +``` + +- [ ] **Step 1.2: Run failing tests** + +Run: `pnpm vitest run src/features/flights-map/calendarRange.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 1.3: Implement the module** + +Create `src/features/flights-map/calendarRange.ts`: + +```ts +/** + * Calendar helpers for the flights-map filter date picker. + * + * All dates are treated as date-only (time zero'd) for day-level comparisons. + * minDate/maxDate form the selectable window; `buildDisabledDates` produces + * the complement of `availableDays` over that window; `findNextEnabledDate` + * advances a current selection forward until it lands on an enabled day. + * + * Matches Angular `FlightsMapFiltersStateService` calendar behavior. + */ + +/** Today with time set to 00:00:00 local. */ +export function today(): Date { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +/** minDate = today - 1 day (Angular parity). */ +export function getMinDate(): Date { + const d = today(); + d.setDate(d.getDate() - 1); + return d; +} + +/** maxDate = today + 6 months (Angular parity). */ +export function getMaxDate(): Date { + const d = today(); + d.setMonth(d.getMonth() + 6); + return d; +} + +/** + * Every date in [minDate, maxDate] whose yyyymmdd is NOT in `availableDays`. + * When `availableDays` is empty, every date in the range is disabled. + */ +export function buildDisabledDates( + minDate: Date, + maxDate: Date, + availableDays: ReadonlyArray, +): Date[] { + const available = new Set(availableDays); + const disabled: Date[] = []; + const cursor = new Date(minDate); + cursor.setHours(0, 0, 0, 0); + const end = new Date(maxDate); + end.setHours(0, 0, 0, 0); + while (cursor.getTime() <= end.getTime()) { + const key = toYyyymmdd(cursor); + if (!available.has(key)) disabled.push(new Date(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + return disabled; +} + +/** + * Advances `current` day-by-day until it hits a non-disabled date, staying + * within [minDate, maxDate]. Returns `null` if no enabled date exists. + * `current` itself is tested first. If `current < minDate`, starts at minDate. + */ +export function findNextEnabledDate( + current: Date, + disabledDates: ReadonlyArray, + minDate: Date, + maxDate: Date, +): Date | null { + const disabledSet = new Set( + disabledDates.map((d) => { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x.getTime(); + }), + ); + const min = new Date(minDate); + min.setHours(0, 0, 0, 0); + const max = new Date(maxDate); + max.setHours(0, 0, 0, 0); + + const cursor = new Date(current); + cursor.setHours(0, 0, 0, 0); + + if (cursor.getTime() < min.getTime()) cursor.setTime(min.getTime()); + + while (cursor.getTime() <= max.getTime()) { + if (!disabledSet.has(cursor.getTime())) return new Date(cursor); + cursor.setDate(cursor.getDate() + 1); + } + return null; +} + +function toYyyymmdd(d: Date): string { + const y = d.getFullYear().toString(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}${m}${day}`; +} +``` + +- [ ] **Step 1.4: Run tests** + +Run: `pnpm vitest run src/features/flights-map/calendarRange.test.ts` +Expected: PASS — 11 tests. + +- [ ] **Step 1.5: Commit** + +```bash +git add src/features/flights-map/calendarRange.ts src/features/flights-map/calendarRange.test.ts +git commit -m "Add calendarRange helpers for flights-map date picker window and snapping" +``` + +--- + +## Task 2: `useGeolocationDefault` hook + +**Files:** +- Create: `src/features/flights-map/hooks/useGeolocationDefault.ts` +- Create: `src/features/flights-map/hooks/useGeolocationDefault.test.tsx` + +- [ ] **Step 2.1: Write failing tests** + +Create `src/features/flights-map/hooks/useGeolocationDefault.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { StrictMode, type ReactNode } from "react"; +import { useGeolocationDefault } from "./useGeolocationDefault.js"; +import { transformDictionaries } from "@/shared/dictionaries/index.js"; +import type { IDictionaries, IRawDictionaries } from "@/shared/dictionaries/index.js"; +import type { IFlightsMapFilterState } from "../types.js"; + +const raw: IRawDictionaries = { + regions: [], + countries: [{ code: "RU", title: { ru: "Россия" }, world_region_id: 500374 }], + cities: [ + { code: "MOW", title: { ru: "Москва" }, country_code: "RU", has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", title: { ru: "Санкт-Петербург" }, country_code: "RU", has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], + airports: [ + { code: "SVO", city_code: "MOW", title: { ru: "Шереметьево" }, has_afl_flights: true, location: { lat: 55, lon: 37 } }, + { code: "LED", city_code: "LED", title: { ru: "Пулково" }, has_afl_flights: true, location: { lat: 60, lon: 30 } }, + ], +}; +const dictionaries: IDictionaries = transformDictionaries(raw, "ru"); + +function makeFilter( + overrides: Partial = {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +interface GeolocationMock { + getCurrentPosition: ReturnType; +} + +function installGeolocation(mock: GeolocationMock | undefined): void { + Object.defineProperty(navigator, "geolocation", { + value: mock, + writable: true, + configurable: true, + }); +} + +function successMock(lat: number, lon: number): GeolocationMock { + return { + getCurrentPosition: vi.fn((success: PositionCallback) => { + success({ + coords: { + latitude: lat, + longitude: lon, + accuracy: 0, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + toJSON: () => ({}), + }, + timestamp: 0, + toJSON: () => ({}), + } as GeolocationPosition); + }), + }; +} + +function errorMock(): GeolocationMock { + return { + getCurrentPosition: vi.fn((_success, error?: PositionErrorCallback) => { + error?.({ + code: 1, + message: "permission denied", + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + } as GeolocationPositionError); + }), + }; +} + +describe("useGeolocationDefault", () => { + let lastState: IFlightsMapFilterState = makeFilter(); + let setFilterState: (updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState) => void; + + beforeEach(() => { + lastState = makeFilter(); + setFilterState = (updater) => { + lastState = updater(lastState); + }; + }); + + afterEach(() => { + installGeolocation(undefined); + }); + + it("sets departure to nearest city when filter is empty and geolocation succeeds", () => { + installGeolocation(successMock(55.1, 37.1)); // near MOW + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBe("MOW"); + }); + + it("does not update when filter.departure is already set", () => { + lastState = makeFilter({ departure: "LED" }); + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBe("LED"); + }); + + it("does not update when filter.arrival is already set", () => { + lastState = makeFilter({ arrival: "MOW" }); + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when geolocation permission is denied", () => { + installGeolocation(errorMock()); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when navigator.geolocation is missing", () => { + installGeolocation(undefined); + renderHook(() => + useGeolocationDefault(dictionaries, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("does nothing when dictionaries is null at callback time", () => { + installGeolocation(successMock(55.1, 37.1)); + renderHook(() => + useGeolocationDefault(null, lastState, setFilterState), + ); + expect(lastState.departure).toBeUndefined(); + }); + + it("invokes getCurrentPosition exactly once under StrictMode", () => { + const mock = successMock(55.1, 37.1); + installGeolocation(mock); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook( + () => useGeolocationDefault(dictionaries, lastState, setFilterState), + { wrapper }, + ); + expect(mock.getCurrentPosition).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2.2: Run failing tests** + +Run: `pnpm vitest run src/features/flights-map/hooks/useGeolocationDefault.test.tsx` +Expected: FAIL — module not found. + +- [ ] **Step 2.3: Implement the hook** + +Create `src/features/flights-map/hooks/useGeolocationDefault.ts`: + +```ts +/** + * Sets the flights-map filter's departure to the nearest city based on + * browser geolocation, if the user has not already typed a departure or + * arrival. Fires once per mount. Silent on permission denial or missing + * geolocation API. Matches Angular `UserLocationService` + `DictionariesService.locate`. + */ + +import { useEffect, useRef } from "react"; +import { + findCityByCoord, + type IDictionaries, +} from "@/shared/dictionaries/index.js"; +import type { IFlightsMapFilterState } from "../types.js"; + +export function useGeolocationDefault( + dictionaries: IDictionaries | null, + filterState: IFlightsMapFilterState, + setFilterState: ( + updater: (prev: IFlightsMapFilterState) => IFlightsMapFilterState, + ) => void, +): void { + const appliedRef = useRef(false); + // Refs hold latest values so the async callback reads current snapshots + // while the effect itself runs only once. + const dictRef = useRef(dictionaries); + dictRef.current = dictionaries; + const filterRef = useRef(filterState); + filterRef.current = filterState; + const setFilterRef = useRef(setFilterState); + setFilterRef.current = setFilterState; + + useEffect(() => { + if (appliedRef.current) return; + if (typeof navigator === "undefined" || !navigator.geolocation) { + appliedRef.current = true; + return; + } + + appliedRef.current = true; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const d = dictRef.current; + const f = filterRef.current; + if (!d) return; + if (f.departure || f.arrival) return; + + const city = findCityByCoord( + d, + pos.coords.latitude, + pos.coords.longitude, + ); + if (!city) return; + + setFilterRef.current((prev) => + prev.departure || prev.arrival + ? prev + : { ...prev, departure: city.code }, + ); + }, + () => { + // Silent: permission denied / timeout / unavailable. + }, + { enableHighAccuracy: false, timeout: 5000 }, + ); + }, []); +} +``` + +- [ ] **Step 2.4: Run tests** + +Run: `pnpm vitest run src/features/flights-map/hooks/useGeolocationDefault.test.tsx` +Expected: PASS — 7 tests. + +- [ ] **Step 2.5: Commit** + +```bash +git add src/features/flights-map/hooks/useGeolocationDefault.ts src/features/flights-map/hooks/useGeolocationDefault.test.tsx +git commit -m "Add useGeolocationDefault hook for flights-map departure pre-fill" +``` + +--- + +## Task 3: Wire calendar into `FlightsMapFilter` + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapFilter.tsx` + +- [ ] **Step 3.1: Add imports** + +Open `src/features/flights-map/components/FlightsMapFilter.tsx`. At the top, update the React import to include `useEffect` and `useMemo`, and add the calendar-range import: + +```tsx +import { type FC, useState, useCallback, useEffect, useMemo, type FormEvent } from "react"; +``` + +Also add, near the `useCitySearch` import: + +```tsx +import { + getMinDate, + getMaxDate, + buildDisabledDates, + findNextEnabledDate, +} from "../calendarRange.js"; +``` + +- [ ] **Step 3.2: Compute min/max/disabledDates inside the component** + +Inside the `FlightsMapFilter` component, after the existing hook calls (`useTranslation`, `useState`, `useCitySearch`) and before `handleDepartureSearch`, add: + +```tsx +const minDate = useMemo(() => getMinDate(), []); +const maxDate = useMemo(() => getMaxDate(), []); + +const disabledDates = useMemo( + () => buildDisabledDates(minDate, maxDate, availableDays ?? []), + [minDate, maxDate, availableDays], +); +``` + +- [ ] **Step 3.3: Add snap effect** + +Below the `disabledDates` memo (still inside the component), add: + +```tsx +useEffect(() => { + if (!value.date) return; + const current = yyyymmddToDate(value.date); + if (!current) return; + + const snapped = findNextEnabledDate(current, disabledDates, minDate, maxDate); + const snappedYyyymmdd = snapped ? dateToYyyymmdd(snapped) : undefined; + + if (snappedYyyymmdd !== value.date) { + onChange({ ...value, date: snappedYyyymmdd }); + } +}, [disabledDates, minDate, maxDate, value, onChange]); +``` + +- [ ] **Step 3.4: Pass the new props to ``** + +Find the existing `` block. Replace it with: + +```tsx + handleDateChange(e.value as Date | null)} + minDate={minDate} + maxDate={maxDate} + disabledDates={disabledDates} + dateFormat="dd.mm.yy" + showIcon + className="input--filter" + inputId="fm-date" + data-testid="fm-date-input" +/> +``` + +- [ ] **Step 3.5: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: no output. + +- [ ] **Step 3.6: Run full suite** + +Run: `pnpm vitest run` +Expected: all pass. + +- [ ] **Step 3.7: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapFilter.tsx +git commit -m "Wire availableDays into FlightsMapFilter Calendar with snap-to-nearest" +``` + +--- + +## Task 4: `FlightsMapFilter` Calendar tests + +**Files:** +- Create: `src/features/flights-map/components/FlightsMapFilter.test.tsx` + +- [ ] **Step 4.1: Write the test file** + +Create `src/features/flights-map/components/FlightsMapFilter.test.tsx`: + +```tsx +/** + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; +import { FlightsMapFilter } from "./FlightsMapFilter.js"; +import type { IFlightsMapFilterState } from "../types.js"; + +// Capture props passed to PrimeReact Calendar. +let lastCalendarProps: Record | null = null; +vi.mock("primereact/calendar", () => ({ + Calendar: (props: Record) => { + lastCalendarProps = props; + return
; + }, +})); + +// Stub PrimeReact AutoComplete so rendering is cheap. +vi.mock("primereact/autocomplete", () => ({ + AutoComplete: (props: Record) => ( + + ), +})); + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "ru" }, + }), +})); + +vi.mock("@/shared/hooks/useCitySearch.js", () => ({ + useCitySearch: () => ({ suggestions: [], search: vi.fn() }), +})); + +function filter( + overrides: Partial = {}, +): IFlightsMapFilterState { + return { + connections: false, + domestic: false, + international: false, + ...overrides, + }; +} + +function yyyymmdd(d: Date): string { + const y = d.getFullYear().toString(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}${m}${day}`; +} + +function addDays(base: Date, n: number): Date { + const d = new Date(base); + d.setDate(d.getDate() + n); + d.setHours(0, 0, 0, 0); + return d; +} + +describe("FlightsMapFilter — Calendar wiring", () => { + beforeEach(() => { + lastCalendarProps = null; + }); + + it("passes minDate and maxDate to Calendar", () => { + const onChange = vi.fn(); + render(); + + const min = lastCalendarProps!["minDate"] as Date; + const max = lastCalendarProps!["maxDate"] as Date; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const expectedMin = addDays(today, -1); + const expectedMax = new Date(today); + expectedMax.setMonth(expectedMax.getMonth() + 6); + + expect(min.getTime()).toBe(expectedMin.getTime()); + expect(max.getTime()).toBe(expectedMax.getTime()); + }); + + it("disables every date when availableDays is empty", () => { + const onChange = vi.fn(); + render( + , + ); + + const disabled = lastCalendarProps!["disabledDates"] as Date[]; + expect(disabled.length).toBeGreaterThan(180); // 6 months of days, inclusive + }); + + it("excludes available days from disabledDates", () => { + const onChange = vi.fn(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const plusTwo = addDays(today, 2); + + render( + , + ); + + const disabled = lastCalendarProps!["disabledDates"] as Date[]; + const contains = disabled.some((d) => { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x.getTime() === plusTwo.getTime(); + }); + expect(contains).toBe(false); + }); + + it("snaps a disabled value.date forward to the next available day", () => { + const onChange = vi.fn(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const plusOne = addDays(today, 1); + const plusTwo = addDays(today, 2); + + render( + , + ); + + expect(onChange).toHaveBeenCalled(); + const called = onChange.mock.calls[0]![0] as IFlightsMapFilterState; + expect(called.date).toBe(yyyymmdd(plusTwo)); + }); + + it("clears value.date when no enabled date exists in the window", () => { + const onChange = vi.fn(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const plusOne = addDays(today, 1); + + render( + , + ); + + expect(onChange).toHaveBeenCalled(); + const called = onChange.mock.calls[0]![0] as IFlightsMapFilterState; + expect(called.date).toBeUndefined(); + }); + + it("does not snap when the current date is already enabled", () => { + const onChange = vi.fn(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const plusTwo = addDays(today, 2); + + render( + , + ); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 4.2: Run tests** + +Run: `pnpm vitest run src/features/flights-map/components/FlightsMapFilter.test.tsx` +Expected: PASS — 6 tests. + +- [ ] **Step 4.3: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapFilter.test.tsx +git commit -m "Test FlightsMapFilter Calendar min/max/disabledDates + snap effect" +``` + +--- + +## Task 5: Wire `useGeolocationDefault` into `FlightsMapStartPage` + +**Files:** +- Modify: `src/features/flights-map/components/FlightsMapStartPage.tsx` + +- [ ] **Step 5.1: Add the hook call** + +Open `src/features/flights-map/components/FlightsMapStartPage.tsx`. + +Add the import near the other hooks imports: + +```tsx +import { useGeolocationDefault } from "../hooks/useGeolocationDefault.js"; +``` + +Find the existing `useDictionaries(lang)` destructure. Directly below the `[filterState, setFilterState] = useState(...)` line, add: + +```tsx +useGeolocationDefault(dictionaries, filterState, setFilterState); +``` + +- [ ] **Step 5.2: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: no output. + +- [ ] **Step 5.3: Full suite** + +Run: `pnpm vitest run` +Expected: all pass. The existing page test mocks `useDictionaries` but not `navigator.geolocation` — the hook early-returns when `navigator.geolocation` is undefined in jsdom. If some test has a real `navigator.geolocation` stub lingering from a previous test, a `beforeEach` reset may be needed — if so, add: + +```tsx +beforeEach(() => { + Object.defineProperty(navigator, "geolocation", { + value: undefined, + writable: true, + configurable: true, + }); +}); +``` + +to the existing `FlightsMapStartPage.test.tsx`. Verify first by running the suite; only add if tests fail. + +- [ ] **Step 5.4: Commit** + +```bash +git add src/features/flights-map/components/FlightsMapStartPage.tsx +git commit -m "Invoke useGeolocationDefault on FlightsMapStartPage mount" +``` + +If step 5.3 required a test file change, include it in the commit. + +--- + +## Task 6: Final verification + +- [ ] **Step 6.1: Typecheck** + +Run: `pnpm tsc --noEmit` +Expected: no output. + +- [ ] **Step 6.2: Full vitest suite** + +Run: `pnpm vitest run` +Expected: all pass. Count delta: ~24 new tests (11 calendarRange + 7 useGeolocationDefault + 6 FlightsMapFilter). + +- [ ] **Step 6.3: Feature-scoped run** + +Run: `pnpm vitest run src/features/flights-map/` +Expected: all feature tests pass. + +--- + +## Self-Review Log + +Ran against the spec: + +- **Spec coverage.** + - `today()`, `getMinDate()`, `getMaxDate()`, `buildDisabledDates()`, `findNextEnabledDate()` → Task 1. + - `useGeolocationDefault` hook with appliedRef + refs for dict/filter/setter → Task 2. + - FlightsMapFilter `minDate`/`maxDate` memos + `disabledDates` memo + snap `useEffect` → Task 3. + - FlightsMapFilter Calendar prop wiring → Task 3. + - FlightsMapStartPage `useGeolocationDefault(...)` call → Task 5. + - Tests: calendarRange (Task 1), useGeolocationDefault (Task 2), FlightsMapFilter Calendar wiring (Task 4), page integration covered by existing tests + Task 6 full-suite run. +- **Placeholders.** None. +- **Type consistency.** `IDictionaries` imported from `@/shared/dictionaries/index.js` consistently. `IFlightsMapFilterState` shape matches across all files. Helper signatures (`buildDisabledDates(min, max, availableDays)`, `findNextEnabledDate(current, disabled, min, max)`) used identically in implementation, tests, and the FlightsMapFilter consumer. +- **Known trade-off.** `FlightsMapFilter` snap effect has `value` + `onChange` in deps. This makes the effect re-run on every re-render where `value` changes, but the inner `snappedYyyymmdd !== value.date` guard prevents infinite loops (documented in the spec and inline comment). +- **StrictMode parity.** `appliedRef` ensures exactly one `getCurrentPosition` call under StrictMode. Covered by Task 2.5 test.