Grey out non-operating days in filter calendars (TIRREDESIGN-12)
CI / ci (push) Failing after 35s
Deploy / build-and-deploy (push) Failing after 5s

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:
2026-04-22 14:17:00 +03:00
parent 7cc0327a12
commit c2f2c9e089
8 changed files with 226 additions and 25 deletions
@@ -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