# 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 `<Calendar />` 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 `<Calendar />`, 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<string>,
): 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<Date>,
  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> = {},
): IFlightsMapFilterState {
  return {
    connections: false,
    domestic: false,
    international: false,
    ...overrides,
  };
}

interface GeolocationMock {
  getCurrentPosition: ReturnType<typeof vi.fn>;
}

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 }) => (
      <StrictMode>{children}</StrictMode>
    );
    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 `<Calendar />`**

Find the existing `<Calendar ... />` block. Replace it with:

```tsx
<Calendar
  value={value.date ? yyyymmddToDate(value.date) : null}
  onChange={(e) => 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<string, unknown> | null = null;
vi.mock("primereact/calendar", () => ({
  Calendar: (props: Record<string, unknown>) => {
    lastCalendarProps = props;
    return <div data-testid="fm-date-input" />;
  },
}));

// Stub PrimeReact AutoComplete so rendering is cheap.
vi.mock("primereact/autocomplete", () => ({
  AutoComplete: (props: Record<string, unknown>) => (
    <input data-testid={props["data-testid"] as string} />
  ),
}));

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> = {},
): 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(<FlightsMapFilter value={filter()} onChange={onChange} />);

    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(
      <FlightsMapFilter value={filter()} availableDays={[]} onChange={onChange} />,
    );

    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(
      <FlightsMapFilter
        value={filter()}
        availableDays={[yyyymmdd(plusTwo)]}
        onChange={onChange}
      />,
    );

    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(
      <FlightsMapFilter
        value={filter({ date: yyyymmdd(plusOne) })}
        availableDays={[yyyymmdd(plusTwo)]}
        onChange={onChange}
      />,
    );

    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(
      <FlightsMapFilter
        value={filter({ date: yyyymmdd(plusOne) })}
        availableDays={[]}
        onChange={onChange}
      />,
    );

    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(
      <FlightsMapFilter
        value={filter({ date: yyyymmdd(plusTwo) })}
        availableDays={[yyyymmdd(plusTwo)]}
        onChange={onChange}
      />,
    );

    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<IFlightsMapFilterState>(...)` 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.
