diff --git a/docs/superpowers/specs/2026-04-17-flights-map-c5-calendar-geolocation-design.md b/docs/superpowers/specs/2026-04-17-flights-map-c5-calendar-geolocation-design.md new file mode 100644 index 00000000..1f7435e8 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-flights-map-c5-calendar-geolocation-design.md @@ -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, +): 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, + 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 `` block 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" +/> +``` + +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 `` 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.