diff --git a/src/features/flights-map/calendarRange.test.ts b/src/features/flights-map/calendarRange.test.ts new file mode 100644 index 00000000..6bba7806 --- /dev/null +++ b/src/features/flights-map/calendarRange.test.ts @@ -0,0 +1,114 @@ +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(); + }); +}); diff --git a/src/features/flights-map/calendarRange.ts b/src/features/flights-map/calendarRange.ts new file mode 100644 index 00000000..7434259b --- /dev/null +++ b/src/features/flights-map/calendarRange.ts @@ -0,0 +1,96 @@ +/** + * 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, +): 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, + 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}`; +}