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.
This commit is contained in:
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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<OnlineBoardFilterProps> = ({
|
||||
initialDeparture,
|
||||
initialArrival,
|
||||
@@ -150,6 +188,53 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
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<CalendarParams | null>(() => {
|
||||
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<CalendarParams | null>(() => {
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
onChange={(e) => setRouteDate(e.value as Date | null)}
|
||||
minDate={boardMinDate}
|
||||
maxDate={boardMaxDate}
|
||||
disabledDates={routeDisabledDates}
|
||||
dateFormat="dd.mm.yy"
|
||||
placeholder={t("SHARED.DATE_FORMAT")}
|
||||
showIcon
|
||||
|
||||
@@ -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<string, unknown>) => (
|
||||
<input
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useApiClient } from "@/shared/api/provider.js";
|
||||
import { getCalendarDays } from "../api.js";
|
||||
import type { CalendarParams } from "../api.js";
|
||||
@@ -19,21 +19,29 @@ export interface UseCalendarDaysResult {
|
||||
|
||||
/**
|
||||
* Hook for the calendar strip. Fetches available flight days for the
|
||||
* given search context.
|
||||
* given search context. Pass `null` while user input is incomplete to
|
||||
* skip the fetch entirely (the hook returns an empty `days` array so
|
||||
* the filter's disabled-dates derivation is a no-op until the user has
|
||||
* typed enough to resolve a search segment).
|
||||
*/
|
||||
export function useCalendarDays(params: CalendarParams): UseCalendarDaysResult {
|
||||
export function useCalendarDays(
|
||||
params: CalendarParams | null,
|
||||
): UseCalendarDaysResult {
|
||||
const client = useApiClient();
|
||||
const [days, setDays] = useState<string[]>([]);
|
||||
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 };
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
|
||||
@@ -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<ScheduleFilterProps> = ({
|
||||
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<IScheduleCalendarParams | null>(() => {
|
||||
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<IScheduleCalendarParams | null>(() => {
|
||||
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<ScheduleFilterProps> = ({
|
||||
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<ScheduleFilterProps> = ({
|
||||
selectionMode="range"
|
||||
minDate={scheduleMinDate}
|
||||
maxDate={scheduleMaxDate}
|
||||
disabledDates={returnDisabledDates}
|
||||
dateFormat="dd.mm.yy"
|
||||
placeholder={`${t("SHARED.DATE_FORMAT")} - ${t("SHARED.DATE_FORMAT")}`}
|
||||
showIcon
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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 };
|
||||
|
||||
@@ -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<string, unknown>) => (
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user