# Flights Map C.5: Calendar + Geolocation — Design

**Date:** 2026-04-17
**Author:** brainstorming session
**Scope:** Sub-feature C.5 of Gap C (Flights Map rebuild) — final sub-feature.
**Status:** Approved
**Depends on:** C.1 (dictionaries — `findCityByCoord`), C.2, C.3, C.4 — all landed.

## Goal

Close the remaining Angular parity gaps in the Flights Map filter panel:

1. Wire `useFlightsMapCalendar.availableDays` into the date picker as `disabledDates` (inverse over a `[today-1, today+6mo]` window) with min/max bounds and Angular's "snap to nearest enabled date" behavior when the current selection becomes disabled.
2. Add a geolocation-based default departure: on page mount, if the user hasn't typed anything, resolve the nearest city from browser geolocation and set it as the departure.

The exchange button is already implemented (C.2 era); no changes here.

## Non-Goals

- Validation-error reset on exchange (Angular has it; React doesn't have validation and doesn't need it).
- Any new UI elements or restyling.
- Any API changes — `useFlightsMapCalendar` already returns `availableDays`.

## Architecture

Two new modules, two modified.

```
src/features/flights-map/
├── calendarRange.ts                        NEW (pure helpers)
├── calendarRange.test.ts                   NEW
├── hooks/
│   ├── useGeolocationDefault.ts            NEW
│   └── useGeolocationDefault.test.tsx      NEW
└── components/
    ├── FlightsMapFilter.tsx                MODIFY (consume availableDays, min/max, snap effect)
    ├── FlightsMapFilter.test.tsx           NEW (Calendar-prop + snap tests)
    └── FlightsMapStartPage.tsx             MODIFY (one line: call useGeolocationDefault)
```

`useFlightsMapCalendar` is untouched — it already returns `availableDays: string[]`. `MapCanvas` is untouched.

## `calendarRange.ts`

Pure helpers for the date-picker window + snapping:

```ts
/** 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] NOT in `availableDays`. When
 * `availableDays` is empty, every date in the range is disabled
 * (Angular's "disable everything until search returns" behavior).
 */
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 yyyymmdd = toYyyymmdd(cursor);
    if (!available.has(yyyymmdd)) 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.
 */
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}`;
}
```

## `useGeolocationDefault` hook

```ts
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);
  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. Matches Angular.
      },
      { enableHighAccuracy: false, timeout: 5000 },
    );
  }, []);
}
```

`appliedRef` prevents a duplicate geolocation request under React 18 StrictMode. Refs for dict/filter/setter let the effect run once while the callback sees current snapshots.

## `FlightsMapFilter` changes

Add imports:

```tsx
import { useEffect, useMemo } from "react";
import {
  getMinDate,
  getMaxDate,
  buildDisabledDates,
  findNextEnabledDate,
} from "../calendarRange.js";
```

Inside the component, add:

```tsx
const minDate = useMemo(() => getMinDate(), []);
const maxDate = useMemo(() => getMaxDate(), []);

const disabledDates = useMemo(
  () => buildDisabledDates(minDate, maxDate, availableDays ?? []),
  [minDate, maxDate, availableDays],
);

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]);
```

Replace the existing `<Calendar ... />` block 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"
/>
```

The snap effect deps include `value` and `onChange`. The inner `snappedYyyymmdd !== value.date` comparison prevents infinite loops — once snapped, the next run of the effect sees the already-snapped value and skips.

## `FlightsMapStartPage` change

One line, after the existing `dictionaries` + `filterState` declarations:

```tsx
useGeolocationDefault(dictionaries, filterState, setFilterState);
```

## Data flow

```
mount
  └─ useGeolocationDefault: navigator.geolocation.getCurrentPosition
      success + no dep/arr + dictionaries present → setFilterState(dep = nearest)
      denied/missing/no-city → silent
  └─ useDictionaries → dictionaries resolve
  └─ user clicks a marker → filterState.departure set
      └─ searchParams memo changes
      └─ useFlightsMapCalendar(calendarParams) → availableDays
      └─ FlightsMapFilter receives availableDays
          → buildDisabledDates → disabledDates
          → Calendar re-renders with new disabledDates
          → snap effect: if value.date disabled, onChange(snappedDate)
```

## Error handling / edge cases

- **Dictionaries null when geolocation resolves.** Callback checks `dictRef.current`; returns silently. Geolocation is one-shot per mount; user's first manual action is the next signal.
- **User types before geolocation resolves.** Callback-time `departure || arrival` guard + functional `setFilterState` update prevent overwriting.
- **Permission denied / HTTPS-only / no geolocation API.** Silent. Angular parity.
- **`findCityByCoord` returns undefined** (empty dictionaries). Silent early-return.
- **Calendar empty `availableDays`** (initial state or loading). All dates disabled. Snap effect runs; `findNextEnabledDate` returns `null`; snap clears `value.date`. Matches Angular's "disable everything until search returns + clear unavailable date."
- **User selects a date then changes departure.** `availableDays` changes → disabledDates recomputes → snap advances or clears. Matches Angular.
- **StrictMode double-mount.** `appliedRef` prevents duplicate geolocation request.
- **`availableDays` entries outside window** (defensive). `buildDisabledDates` iterates only the window; out-of-range entries are silently ignored.
- **Exchange button.** Already implemented. No changes in C.5.

## Testing

### `calendarRange.test.ts` (pure, no DOM)

- `getMinDate()` returns today - 1 day, time zero.
- `getMaxDate()` returns today + 6 months, time zero.
- `buildDisabledDates` with empty `availableDays` → every date in the window.
- `buildDisabledDates` with all dates available → `[]`.
- `buildDisabledDates` with partial availability → exact complement (spot-check specific dates in/out).
- `buildDisabledDates` ignores out-of-window `availableDays` entries.
- `findNextEnabledDate` with current enabled → returns current.
- `findNextEnabledDate` with current disabled + next day enabled → returns next day.
- `findNextEnabledDate` with all disabled → returns `null`.
- `findNextEnabledDate` with current < minDate → starts at minDate.
- `findNextEnabledDate` with current > maxDate → returns `null`.

Tests pass explicit Date instances (not `new Date()` inside tests) to avoid timezone flakiness.

### `useGeolocationDefault.test.tsx` (jsdom)

Stubs `navigator.geolocation` via direct assignment on a `Object.defineProperty(navigator, "geolocation", ...)` or `vi.stubGlobal("navigator", { geolocation: mock })` pattern.

- **Success + empty filter** → `setFilterState` called once; resulting state's `departure` equals the nearest-city code.
- **Success + filter.departure set** → `setFilterState` not called (or called but returns `prev` unchanged).
- **Success + filter.arrival set** → same.
- **Error callback** (permission denied) → `setFilterState` not called; no exception thrown.
- **Missing `navigator.geolocation`** → hook returns silently; `setFilterState` never called.
- **Dictionaries null at callback time** → no update.
- **StrictMode double-mount** → `getCurrentPosition` invoked exactly once.

### `FlightsMapFilter.test.tsx` (new file)

Mocks PrimeReact's `<Calendar />` as a function component that captures props into a test-scoped variable.

- **Calendar receives minDate / maxDate.** Captured props contain Date instances matching today-1 and today+6mo.
- **Calendar receives disabledDates inverse of availableDays.** With `availableDays = [toYyyymmdd(today+2)]`, captured `disabledDates` contains today+1 but not today+2.
- **Snap advances disabled date.** Initial `value.date = toYyyymmdd(today+1)`, `availableDays = [toYyyymmdd(today+2)]` → `onChange` called with `date: toYyyymmdd(today+2)`.
- **Snap clears when no enabled date.** `availableDays = []` → effect calls `onChange` with `date: undefined`.
- **No snap when date already enabled.** `value.date = toYyyymmdd(today+2)` in `availableDays` → `onChange` not called by snap.

Use fixed-time tests by stubbing `Date.now()` or by injecting fixed dates into the helpers. Prefer the latter where possible (keeps tests hermetic).

## Success criteria

- `pnpm tsc --noEmit` clean.
- All new tests pass. Expected delta: ~22 new tests (~11 calendarRange + ~7 useGeolocationDefault + ~5 FlightsMapFilter + ~1 updated FlightsMapStartPage).
- Full suite green.
- Page renders the date picker with all dates initially disabled, then enables matching days after a route is selected (verified via unit tests since real API is WAF-blocked).
- Geolocation pre-fills departure when allowed; silently skips when denied.

## End of Gap C

After C.5 lands, the React Flights Map has full Angular parity for the behaviors in `flights-map-body.component.ts` (~780 Angular LOC) plus the filter wiring. Remaining Angular-only concerns out of Gap C scope:

- Flights Map meta tags / SEO — already handled in `seo.ts` / `json-ld.ts` pre-C.
- Page-title/meta component — page uses the existing layout; no direct port needed.
