diff --git a/src/features/schedule/components/ScheduleStartPage.test.tsx b/src/features/schedule/components/ScheduleStartPage.test.tsx index af18089f..0ba48271 100644 --- a/src/features/schedule/components/ScheduleStartPage.test.tsx +++ b/src/features/schedule/components/ScheduleStartPage.test.tsx @@ -102,6 +102,10 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ getCityCodeByAirportCode: () => undefined, })); +vi.mock("../hooks/useScheduleCalendar.js", () => ({ + useScheduleCalendar: () => ({ days: [], loading: false, loaded: false }), +})); + let geoMockEnabled = false; vi.mock("@/shared/hooks/useGeoCityDefault.js", () => ({ diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index fb13085c..c9ee0eea 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -1,13 +1,14 @@ /** * Schedule start page -- search form for route-based schedule search. * - * No API calls on load. Pure form that navigates to the appropriate - * search route on submit. + * No schedule-search API calls on load. Once both cities are selected, + * fetches route operating days so unavailable dates are greyed out before + * submit, matching Angular's schedule-filter. * * @module */ -import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react"; +import { type FC, useState, useCallback, useEffect, useMemo, useRef, type FormEvent } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { Calendar } from "primereact/calendar"; @@ -36,6 +37,8 @@ import { useGeoCityDefault } from "@/shared/hooks/useGeoCityDefault.js"; import { buildScheduleUrl } from "../url.js"; import { scheduleWindowBounds } from "@/shared/dateWindow.js"; import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js"; +import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js"; +import type { IScheduleCalendarParams } from "../types.js"; import "./ScheduleStartPage.scss"; function toCityCode(code: string, dictionaries: IDictionaries | null): string { @@ -56,6 +59,33 @@ function dateToYyyymmdd(value: Date): string { return `${y}${m}${d}`; } +function dateToIsoYmd(value: Date): string { + const y = value.getFullYear().toString(); + const m = (value.getMonth() + 1).toString().padStart(2, "0"); + const d = value.getDate().toString().padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function computeDisabledDates( + availableYmd: string[], + minDate: Date, + maxDate: Date, +): Date[] { + const available = new Set(availableYmd); + const disabled: Date[] = []; + const cursor = new Date(minDate); + cursor.setHours(0, 0, 0, 0); + + while (cursor.getTime() <= maxDate.getTime()) { + if (!available.has(dateToIsoYmd(cursor))) { + disabled.push(new Date(cursor)); + } + cursor.setDate(cursor.getDate() + 1); + } + + return disabled; +} + function addDays(base: Date, days: number): Date { const result = new Date(base); result.setDate(result.getDate() + days); @@ -245,6 +275,59 @@ export const ScheduleStartPage: FC = () => { const scheduleMinDate = useRef(getScheduleMinDate()).current; const scheduleMaxDate = useRef(getScheduleMaxDate()).current; + const scheduleCalendarBaseDate = useMemo( + () => dateToIsoYmd(scheduleMinDate), + [scheduleMinDate], + ); + + const outboundCalendarParams = useMemo(() => { + const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries); + const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries); + if (!dep || !arr || dep === arr) return null; + return { + date: scheduleCalendarBaseDate, + departure: dep, + arrival: arr, + connections: !directOnly, + }; + }, [departureCode, arrivalCode, dictionaries, directOnly, scheduleCalendarBaseDate]); + + const returnCalendarParams = useMemo(() => { + if (!isRoundTrip) return null; + const dep = toCityCode(departureCode.trim().toUpperCase(), dictionaries); + const arr = toCityCode(arrivalCode.trim().toUpperCase(), dictionaries); + if (!dep || !arr || dep === arr) return null; + return { + date: scheduleCalendarBaseDate, + departure: arr, + arrival: dep, + connections: !directOnly, + }; + }, [departureCode, arrivalCode, dictionaries, directOnly, isRoundTrip, scheduleCalendarBaseDate]); + + const { + days: outboundAvailableDays, + loaded: outboundCalendarLoaded, + } = useScheduleCalendar(outboundCalendarParams); + const { + days: returnAvailableDays, + loaded: returnCalendarLoaded, + } = useScheduleCalendar(returnCalendarParams); + + const outboundDisabledDates = useMemo( + () => + !outboundCalendarLoaded + ? [] + : computeDisabledDates(outboundAvailableDays, scheduleMinDate, scheduleMaxDate), + [outboundAvailableDays, outboundCalendarLoaded, scheduleMinDate, scheduleMaxDate], + ); + const returnDisabledDates = useMemo( + () => + !returnCalendarLoaded + ? [] + : computeDisabledDates(returnAvailableDays, scheduleMinDate, scheduleMaxDate), + [returnAvailableDays, returnCalendarLoaded, scheduleMinDate, scheduleMaxDate], + ); // TZ ยง4.1.9 Table 14 / Angular CalendarInputWeekComponent: when the // selected date range contains today, the Calendar input shows @@ -486,6 +569,7 @@ export const ScheduleStartPage: FC = () => { selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + disabledDates={outboundDisabledDates} selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} @@ -552,6 +636,7 @@ export const ScheduleStartPage: FC = () => { selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + disabledDates={returnDisabledDates} selectOtherMonths dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}