Add Flights Map C.5 implementation plan

Six TDD tasks: calendarRange pure helpers, useGeolocationDefault hook,
FlightsMapFilter Calendar wiring (minDate/maxDate/disabledDates + snap
effect), Calendar prop capture tests, FlightsMapStartPage geolocation
hook call, final verification.
This commit is contained in:
2026-04-17 12:13:50 +03:00
parent 9ee9c6b089
commit 5a18e86bec
@@ -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 `<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.