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:
@@ -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.
|
||||
Reference in New Issue
Block a user