From c58e0ca783c93586f09bb9c962a0ae0476ca21d7 Mon Sep 17 00:00:00 2001 From: Manas Kenge Date: Fri, 2 Jan 2026 23:36:01 +0530 Subject: [PATCH 1/2] fix: Custom time input for availability (#26373) * add custom time input * add unit test * add validation logic --- .../schedules/components/Schedule.tsx | 177 ++++++++++--- .../components/parse-time-string.test.ts | 236 ++++++++++++++++++ 2 files changed, 384 insertions(+), 29 deletions(-) create mode 100644 packages/features/schedules/components/parse-time-string.test.ts diff --git a/packages/features/schedules/components/Schedule.tsx b/packages/features/schedules/components/Schedule.tsx index 9031e0895e9d58..a911d1ffc21bf5 100644 --- a/packages/features/schedules/components/Schedule.tsx +++ b/packages/features/schedules/components/Schedule.tsx @@ -45,7 +45,12 @@ export type SelectInnerClassNames = { }; export type FieldPathByValue = { - [Key in FieldPath]: FieldPathValue extends TValue ? Key : never; + [Key in FieldPath]: FieldPathValue< + TFieldValues, + Key + > extends TValue + ? Key + : never; }[FieldPath]; export const ScheduleDay = ({ @@ -404,12 +409,35 @@ const TimeRangeField = ({ ); }; +export function parseTimeString( + input: string, + timeFormat: number | null +): Date | null { + if (!input.trim()) return null; + + const formats = timeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"]; + const parsed = dayjs(input, formats, true); // strict parsing + + if (!parsed.isValid()) return null; + + const hours = parsed.hour(); + const minutes = parsed.minute(); + + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { + return null; + } + + return new Date(new Date().setUTCHours(hours, minutes, 0, 0)); +} + const LazySelect = ({ value, min, max, userTimeFormat, menuPlacement, + innerClassNames, + onChange, ...props }: Omit>, "value"> & { value: ConfigType; @@ -426,31 +454,100 @@ const LazySelect = ({ }, [filter, value]); const [inputValue, setInputValue] = React.useState(""); + const [timeInputError, setTimeInputError] = React.useState(false); const defaultFilter = React.useMemo(() => createFilter(), []); + + const handleInputChange = React.useCallback( + (newValue: string, actionMeta: { action: string }) => { + setInputValue(newValue); + + if (actionMeta.action === "input-change" && newValue.trim()) { + const trimmedValue = newValue.trim(); + + const formats = + userTimeFormat === 12 ? ["h:mma", "HH:mm"] : ["HH:mm", "h:mma"]; + const parsedTime = dayjs(trimmedValue, formats, true); + const looksLikeTime = /^\d{1,2}:\d{2}(a|p|am|pm)?$/i.test(trimmedValue); + + if (looksLikeTime && !parsedTime.isValid()) { + setTimeInputError(true); + } else if (parsedTime.isValid()) { + const parsedDate = parseTimeString(trimmedValue, userTimeFormat); + if (parsedDate) { + const parsedDayjs = dayjs(parsedDate); + const violatesMin = min ? !parsedDayjs.isAfter(min) : false; + const violatesMax = max ? !parsedDayjs.isBefore(max) : false; + setTimeInputError(Boolean(violatesMin || violatesMax)); + } else { + setTimeInputError(false); + } + } else { + setTimeInputError(false); + } + } else { + setTimeInputError(false); + } + }, + [userTimeFormat, min, max] + ); + const filteredOptions = React.useMemo(() => { - const regex = /^(\d{1,2})(a|p|am|pm)$/i; - const match = inputValue.replaceAll(" ", "").match(regex); - if (!match) { - return options.filter((option) => - defaultFilter({ ...option, data: option.label, value: option.label }, inputValue) - ); + const dropdownOptions = options.filter((option) => + defaultFilter( + { ...option, data: option.label, value: option.label }, + inputValue + ) + ); + + const trimmedInput = inputValue.trim(); + if (trimmedInput) { + const parsedTime = parseTimeString(trimmedInput, userTimeFormat); + + if (parsedTime) { + const parsedDayjs = dayjs(parsedTime); + // Validate against min/max bounds using same logic as filter function + const withinBounds = + (!min || parsedDayjs.isAfter(min)) && + (!max || parsedDayjs.isBefore(max)); + + if (withinBounds) { + const parsedTimestamp = parsedTime.valueOf(); + const existsInOptions = options.some( + (option) => option.value === parsedTimestamp + ); + + if (!existsInOptions) { + const manualOption: IOption = { + label: dayjs(parsedTime) + .utc() + .format(userTimeFormat === 12 ? "h:mma" : "HH:mm"), + value: parsedTimestamp, + }; + return [manualOption, ...dropdownOptions]; + } + } + } } - const [, numberPart, periodPart] = match; - const periodLower = periodPart.toLowerCase(); - const scoredOptions = options - .filter((option) => option.label && option.label.toLowerCase().includes(periodLower)) - .map((option) => { - const labelLower = option.label.toLowerCase(); - const index = labelLower.indexOf(numberPart); - const score = index >= 0 ? index + labelLower.length : Infinity; - return { score, option }; - }) - .sort((a, b) => a.score - b.score); - - const maxScore = scoredOptions[0]?.score; - return scoredOptions.filter((item) => item.score === maxScore).map((item) => item.option); - }, [inputValue, options, defaultFilter]); + return dropdownOptions; + }, [inputValue, options, defaultFilter, userTimeFormat, min, max]); + + const currentValue = dayjs(value).toDate().valueOf(); + const currentOption = + options.find((option) => option.value === currentValue) || + (value + ? { + value: currentValue, + label: dayjs(value) + .utc() + .format(userTimeFormat === 12 ? "h:mma" : "HH:mm"), + } + : null); + + const errorInnerClassNames: SelectInnerClassNames = { + ...innerClassNames, + control: cn(innerClassNames?.control, timeInputError && "!border-error"), + }; return (