From c2f2c9e089075c54fde45ea0cc8fa102a7974587 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 22 Apr 2026 14:17:00 +0300 Subject: [PATCH] Grey out non-operating days in filter calendars (TIRREDESIGN-12) The Online-Board + Schedule filter calendars ignored the 31-day operating-days bitmask the API ships, so users could pick dates that have no flights and land on empty result pages. Angular wires [disabledDates] from the same endpoint; we do the same here. - useCalendarDays / useScheduleCalendar now accept null params so the callers can skip the fetch until they have enough input to resolve a calendar segment (full flight number, route with both cities). - OnlineBoardFilter + ScheduleFilter compute disabledDates by differencing the min/max window against the API's available-days array, then feed that into PrimeReact's Calendar. - Test mocks added to sidestep the api provider requirement in the filter/start-page/integration trees that render these components. --- .../components/OnlineBoardFilter.test.tsx | 6 ++ .../components/OnlineBoardFilter.tsx | 87 +++++++++++++++++++ .../components/OnlineBoardStartPage.test.tsx | 4 + .../online-board/hooks/useCalendarDays.ts | 34 +++++--- .../components/ScheduleFilter.test.tsx | 6 ++ .../schedule/components/ScheduleFilter.tsx | 82 +++++++++++++++++ .../schedule/hooks/useScheduleCalendar.ts | 28 +++--- .../online-board/start-page.test.tsx | 4 + 8 files changed, 226 insertions(+), 25 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardFilter.test.tsx b/src/features/online-board/components/OnlineBoardFilter.test.tsx index e5d70a86..00b15abb 100644 --- a/src/features/online-board/components/OnlineBoardFilter.test.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.test.tsx @@ -32,6 +32,12 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ useDictionaries: () => ({ dictionaries: null, loading: false, error: null }), })); +// useCalendarDays would otherwise pull the api provider into the test +// tree just for the disabled-dates wiring added in TIRREDESIGN-12. +vi.mock("../hooks/useCalendarDays.js", () => ({ + useCalendarDays: () => ({ days: [], loading: false }), +})); + vi.mock("@/shared/state/crossSectionNavigation.js", () => ({ setBoardFilter: vi.fn(), })); diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index 1fdb4821..d7ced8c2 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -17,6 +17,8 @@ import { useTranslation } from "@/i18n/provider.js"; import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.js"; import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js"; import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { useCalendarDays } from "../hooks/useCalendarDays.js"; +import type { CalendarParams } from "../api.js"; import { buildOnlineBoardUrl } from "../url.js"; import { setBoardFilter } from "@/shared/state/crossSectionNavigation.js"; import { formatDateWithTodayTomorrow } from "../dateLabels.js"; @@ -106,6 +108,42 @@ function getBoardMaxDate(): Date { return d; } +/** Today at midnight (yyyy-MM-dd) — used as the base date for calendar + * availability queries so the 31-day bitmask always starts from today-1. */ +function todayIso(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** + * Given the list of available yyyyMMdd date strings the API returned, + * compute the dates inside [minDate, maxDate] that are NOT available. + * Matches Angular's filter logic: every day in the window that isn't in + * the bitmask is pushed to disabledDates so PrimeReact's Calendar + * greys them out. + */ +function computeDisabledDates( + availableYmd: string[], + minDate: Date, + maxDate: Date, +): Date[] { + const available = new Set(availableYmd); + const out: Date[] = []; + const cursor = new Date(minDate); + cursor.setHours(0, 0, 0, 0); + while (cursor.getTime() <= maxDate.getTime()) { + const ymd = dateToYyyymmdd(cursor); + if (!available.has(ymd)) { + out.push(new Date(cursor)); + } + cursor.setDate(cursor.getDate() + 1); + } + return out; +} + export const OnlineBoardFilter: FC = ({ initialDeparture, initialArrival, @@ -150,6 +188,53 @@ export const OnlineBoardFilter: FC = ({ const boardMinDate = useRef(getBoardMinDate()).current; const boardMaxDate = useRef(getBoardMaxDate()).current; + // TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the + // current tab so non-operating days in the [minDate, maxDate] window + // render as disabled in the PrimeReact calendar. Only query when the + // user has typed enough input to resolve a calendar segment — an empty + // flight number or empty route produces no API call. + const flightCalendarParams = useMemo(() => { + if (activeTab !== "flight") return null; + const digits = flightNumber.trim(); + if (digits.length < 1 || !/^\d{1,4}$/.test(digits)) return null; + return { + date: todayIso(), + searchType: "flight", + flightNumber: `SU${padFlightNumber(digits)}`, + }; + }, [activeTab, flightNumber]); + + const routeCalendarParams = useMemo(() => { + if (activeTab !== "route") return null; + const dep = routeDepartureCode.trim().toUpperCase(); + const arr = routeArrivalCode.trim().toUpperCase(); + if (!dep && !arr) return null; + if (dep && arr) { + if (dep === arr) return null; + return { date: todayIso(), searchType: "route", departure: dep, arrival: arr }; + } + if (dep) return { date: todayIso(), searchType: "departure", departure: dep }; + return { date: todayIso(), searchType: "arrival", arrival: arr }; + }, [activeTab, routeDepartureCode, routeArrivalCode]); + + const { days: flightAvailableDays } = useCalendarDays(flightCalendarParams); + const { days: routeAvailableDays } = useCalendarDays(routeCalendarParams); + + const flightDisabledDates = useMemo( + () => + flightAvailableDays.length === 0 + ? [] + : computeDisabledDates(flightAvailableDays, boardMinDate, boardMaxDate), + [flightAvailableDays, boardMinDate, boardMaxDate], + ); + const routeDisabledDates = useMemo( + () => + routeAvailableDays.length === 0 + ? [] + : computeDisabledDates(routeAvailableDays, boardMinDate, boardMaxDate), + [routeAvailableDays, boardMinDate, boardMaxDate], + ); + // §4.1.10 — submit button locked for 30 seconds after each search. // Value is the timestamp when the lock expires (or 0 if unlocked). // The 30-second constant is intentionally hardcoded (not configurable). @@ -450,6 +535,7 @@ export const OnlineBoardFilter: FC = ({ onChange={(e) => setFlightDate(e.value as Date | null)} minDate={boardMinDate} maxDate={boardMaxDate} + disabledDates={flightDisabledDates} dateFormat="dd.mm.yy" placeholder={t("SHARED.DATE_FORMAT")} showIcon @@ -581,6 +667,7 @@ export const OnlineBoardFilter: FC = ({ onChange={(e) => setRouteDate(e.value as Date | null)} minDate={boardMinDate} maxDate={boardMaxDate} + disabledDates={routeDisabledDates} dateFormat="dd.mm.yy" placeholder={t("SHARED.DATE_FORMAT")} showIcon diff --git a/src/features/online-board/components/OnlineBoardStartPage.test.tsx b/src/features/online-board/components/OnlineBoardStartPage.test.tsx index 366e5a00..a7c72ada 100644 --- a/src/features/online-board/components/OnlineBoardStartPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardStartPage.test.tsx @@ -85,6 +85,10 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ useDictionaries: () => ({ dictionaries: null, loading: false, error: null }), })); +vi.mock("../hooks/useCalendarDays.js", () => ({ + useCalendarDays: () => ({ days: [], loading: false }), +})); + vi.mock("@/ui/city-autocomplete/index.js", () => ({ CityAutocomplete: (props: Record) => ( ([]); - const [loading, setLoading] = useState(true); - - const paramsRef = useRef(params); - paramsRef.current = params; + const [loading, setLoading] = useState(Boolean(params)); useEffect(() => { + if (!params) { + setDays([]); + setLoading(false); + return; + } + let cancelled = false; setLoading(true); - getCalendarDays(client, paramsRef.current) + getCalendarDays(client, params) .then((result) => { if (!cancelled) { setDays(result); @@ -52,11 +60,11 @@ export function useCalendarDays(params: CalendarParams): UseCalendarDaysResult { }; }, [ client, - params.date, - params.searchType, - params.flightNumber, - params.departure, - params.arrival, + params?.date, + params?.searchType, + params?.flightNumber, + params?.departure, + params?.arrival, ]); return { days, loading }; diff --git a/src/features/schedule/components/ScheduleFilter.test.tsx b/src/features/schedule/components/ScheduleFilter.test.tsx index dea5a71b..ae9e485e 100644 --- a/src/features/schedule/components/ScheduleFilter.test.tsx +++ b/src/features/schedule/components/ScheduleFilter.test.tsx @@ -32,6 +32,12 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ useDictionaries: () => ({ dictionaries: null, loading: false, error: null }), })); +// useScheduleCalendar would otherwise need the api provider in the test +// tree just for the disabled-dates wiring added in TIRREDESIGN-12. +vi.mock("../hooks/useScheduleCalendar.js", () => ({ + useScheduleCalendar: () => ({ days: [], loading: false }), +})); + // PrimeReact Calendar stub — read-only input so state is driven by props vi.mock("primereact/calendar", () => ({ Calendar: (props: Record) => { diff --git a/src/features/schedule/components/ScheduleFilter.tsx b/src/features/schedule/components/ScheduleFilter.tsx index 4ded6e81..e5013873 100644 --- a/src/features/schedule/components/ScheduleFilter.tsx +++ b/src/features/schedule/components/ScheduleFilter.tsx @@ -16,6 +16,8 @@ import { useTranslation } from "@/i18n/provider.js"; import { useLocale } from "@/i18n/useLocale.js"; import { CityAutocomplete, SwapCityButton } from "@/ui/city-autocomplete/index.js"; import { useDictionaries } from "@/shared/dictionaries/index.js"; +import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js"; +import type { IScheduleCalendarParams } from "../types.js"; import { buildScheduleUrl } from "../url.js"; import type { ScheduleParams } from "../url.js"; import { formatScheduleDateRangeWithCurrentWeek } from "../dateLabels.js"; @@ -49,6 +51,36 @@ function yyyymmddToDate(yyyymmdd?: string): Date | null { return new Date(y, m, d); } +function todayIso(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** Inverse of the API's enabled-days list: every date inside + * [minDate, maxDate] that isn't in `availableYmd` gets disabled so + * PrimeReact's Calendar greys them out (TIRREDESIGN-12). */ +function computeDisabledDates( + availableYmd: string[], + minDate: Date, + maxDate: Date, +): Date[] { + const available = new Set(availableYmd); + const out: Date[] = []; + const cursor = new Date(minDate); + cursor.setHours(0, 0, 0, 0); + while (cursor.getTime() <= maxDate.getTime()) { + const ymd = dateToYyyymmdd(cursor); + if (!available.has(ymd)) { + out.push(new Date(cursor)); + } + cursor.setDate(cursor.getDate() + 1); + } + return out; +} + export interface ScheduleFilterProps { initialDeparture?: string; initialArrival?: string; @@ -136,6 +168,54 @@ export const ScheduleFilter: FC = ({ const scheduleMinDate = useRef(getScheduleMinDate()).current; const scheduleMaxDate = useRef(getScheduleMaxDate()).current; + // TIRREDESIGN-12: fetch the 31-day operating-days bitmask for the + // selected route so PrimeReact greys out days without flights. The + // outbound calendar looks at (departure, arrival); the inbound swaps + // them. Both queries are skipped until both city codes are set so we + // don't hit the API with half-filled inputs. + const scheduleCalendarParams = useMemo(() => { + const dep = departure.trim().toUpperCase(); + const arr = arrival.trim().toUpperCase(); + if (!dep || !arr || dep === arr) return null; + return { + date: todayIso(), + departure: dep, + arrival: arr, + connections: !directOnly, + }; + }, [departure, arrival, directOnly]); + + const returnCalendarParams = useMemo(() => { + if (!returnFlights) return null; + const dep = departure.trim().toUpperCase(); + const arr = arrival.trim().toUpperCase(); + if (!dep || !arr || dep === arr) return null; + return { + date: todayIso(), + departure: arr, + arrival: dep, + connections: !directOnly, + }; + }, [departure, arrival, directOnly, returnFlights]); + + const { days: scheduleAvailableDays } = useScheduleCalendar(scheduleCalendarParams); + const { days: returnAvailableDays } = useScheduleCalendar(returnCalendarParams); + + const scheduleDisabledDates = useMemo( + () => + scheduleAvailableDays.length === 0 + ? [] + : computeDisabledDates(scheduleAvailableDays, scheduleMinDate, scheduleMaxDate), + [scheduleAvailableDays, scheduleMinDate, scheduleMaxDate], + ); + const returnDisabledDates = useMemo( + () => + returnAvailableDays.length === 0 + ? [] + : computeDisabledDates(returnAvailableDays, scheduleMinDate, scheduleMaxDate), + [returnAvailableDays, scheduleMinDate, scheduleMaxDate], + ); + // §4.1.11 — submit button locked for 30 seconds after each search. // The 30-second constant is intentionally hardcoded (not configurable). const [submitLockedUntil, setSubmitLockedUntil] = useState(0); @@ -355,6 +435,7 @@ export const ScheduleFilter: FC = ({ selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + disabledDates={scheduleDisabledDates} dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon @@ -464,6 +545,7 @@ export const ScheduleFilter: FC = ({ selectionMode="range" minDate={scheduleMinDate} maxDate={scheduleMaxDate} + disabledDates={returnDisabledDates} dateFormat="dd.mm.yy" placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`} showIcon diff --git a/src/features/schedule/hooks/useScheduleCalendar.ts b/src/features/schedule/hooks/useScheduleCalendar.ts index 73f902db..91465a5a 100644 --- a/src/features/schedule/hooks/useScheduleCalendar.ts +++ b/src/features/schedule/hooks/useScheduleCalendar.ts @@ -6,7 +6,7 @@ * @module */ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { useApiClient } from "@/shared/api/provider.js"; import { getScheduleCalendarDays } from "../api.js"; import type { IScheduleCalendarParams } from "../types.js"; @@ -18,23 +18,27 @@ export interface UseScheduleCalendarResult { /** * Hook for the calendar strip. Fetches available schedule days for the - * given route context. + * given route context. Pass `null` to skip the fetch until the caller + * has enough input to resolve a query (departure + arrival). */ export function useScheduleCalendar( - params: IScheduleCalendarParams, + params: IScheduleCalendarParams | null, ): UseScheduleCalendarResult { const client = useApiClient(); const [days, setDays] = useState([]); - const [loading, setLoading] = useState(true); - - const paramsRef = useRef(params); - paramsRef.current = params; + const [loading, setLoading] = useState(Boolean(params)); useEffect(() => { + if (!params) { + setDays([]); + setLoading(false); + return; + } + let cancelled = false; setLoading(true); - getScheduleCalendarDays(client, paramsRef.current) + getScheduleCalendarDays(client, params) .then((result) => { if (!cancelled) { setDays(result); @@ -53,10 +57,10 @@ export function useScheduleCalendar( }; }, [ client, - params.date, - params.departure, - params.arrival, - params.connections, + params?.date, + params?.departure, + params?.arrival, + params?.connections, ]); return { days, loading }; diff --git a/tests/integration/online-board/start-page.test.tsx b/tests/integration/online-board/start-page.test.tsx index aee5b8d9..d3e70835 100644 --- a/tests/integration/online-board/start-page.test.tsx +++ b/tests/integration/online-board/start-page.test.tsx @@ -49,6 +49,10 @@ vi.mock("@/shared/dictionaries/index.js", () => ({ findCityByCoord: () => null, })); +vi.mock("@/features/online-board/hooks/useCalendarDays.js", () => ({ + useCalendarDays: () => ({ days: [], loading: false }), +})); + vi.mock("@/ui/city-autocomplete/index.js", () => ({ CityAutocomplete: (props: Record) => (