Add design spec for Flights Map C.5 (Calendar + Geolocation)

Final C-gap sub-feature: calendarRange pure helpers
(getMinDate/getMaxDate/buildDisabledDates/findNextEnabledDate), snap-to-
nearest-enabled effect in FlightsMapFilter, and useGeolocationDefault
hook that pre-fills departure from browser position on mount when no
dep/arr is already set.
This commit is contained in:
2026-04-17 12:07:48 +03:00
parent f4b96b8248
commit 9ee9c6b089
@@ -0,0 +1,349 @@
# 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.