diff --git a/components/dash-core-components/src/components/DateRangeSlider.tsx b/components/dash-core-components/src/components/DateRangeSlider.tsx new file mode 100644 index 0000000000..c0cbf70245 --- /dev/null +++ b/components/dash-core-components/src/components/DateRangeSlider.tsx @@ -0,0 +1,42 @@ +import React, {lazy, Suspense} from 'react'; +import {PersistedProps, PersistenceTypes, DateRangeSliderProps} from '../types'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; + +import './css/sliders.css'; + +const RealDateRangeSlider = lazy(dateRangeSlider); + +/** + * A date range slider component. + * Used for specifying a range of dates with optional disabled date indicators + * and calendar-aware stepping. + */ +export default function DateRangeSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + allow_direct_input = true, + disabled_dates_indicator = true, + ...props +}: DateRangeSliderProps) { + return ( + + + + ); +} + +DateRangeSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/components/DateSlider.tsx b/components/dash-core-components/src/components/DateSlider.tsx new file mode 100644 index 0000000000..6904f5ba3f --- /dev/null +++ b/components/dash-core-components/src/components/DateSlider.tsx @@ -0,0 +1,85 @@ +import {omit} from 'ramda'; +import React, {lazy, Suspense, useCallback, useMemo} from 'react'; +import { + PersistedProps, + PersistenceTypes, + DateSliderProps, + DateRangeSliderProps, +} from '../types'; +import dateRangeSlider from '../utils/LazyLoader/dateRangeSlider'; +import './css/sliders.css'; + +const RealSlider = lazy(dateRangeSlider); + +/** + * A slider component for selecting a single date. + * This is a wrapper around DateRangeSlider that handles date values. + */ +export default function DateSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + allow_direct_input = true, + setProps, + value, + drag_value, + id, + vertical = false, + ...props +}: DateSliderProps) { + // Convert single date value to array for DateRangeSlider + const mappedValue: DateRangeSliderProps['value'] = useMemo(() => { + return typeof value === 'string' ? [value] : value; + }, [value]); + + // Convert single date drag value to array for DateRangeSlider + const mappedDragValue: DateRangeSliderProps['drag_value'] = useMemo(() => { + return typeof drag_value === 'string' ? [drag_value] : drag_value; + }, [drag_value]); + + const mappedSetProps: DateRangeSliderProps['setProps'] = useCallback( + newProps => { + const {value, drag_value} = newProps; + const mappedProps: Partial = omit( + ['value', 'drag_value', 'setProps'], + newProps + ); + if ('value' in newProps) { + mappedProps.value = value ? value[0] : value; + } + if ('drag_value' in newProps) { + mappedProps.drag_value = drag_value + ? drag_value[0] + : drag_value; + } + + setProps(mappedProps); + }, + [setProps] + ); + + return ( + + + + ); +} + +DateSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/components/css/sliders.css b/components/dash-core-components/src/components/css/sliders.css index cb6d4e41cd..206b976e8a 100644 --- a/components/dash-core-components/src/components/css/sliders.css +++ b/components/dash-core-components/src/components/css/sliders.css @@ -55,7 +55,7 @@ .dash-slider-thumb { position: relative; - z-index: 1; + z-index: 10; display: block; width: 16px; height: 16px; @@ -97,6 +97,7 @@ color: var(--Dash-Text-Strong); white-space: nowrap; pointer-events: none; + z-index: 10; } .dash-slider-mark-outside-selection { @@ -147,6 +148,7 @@ background-color: var(--Dash-Fill-Inverse-Strong); user-select: none; z-index: 1000; + width: max-content; fill: var(--Dash-Fill-Inverse-Strong); } @@ -195,6 +197,11 @@ min-width: 0; } +.dash-date-range-slider-wrapper { + position: relative; + flex: 1; +} + .dash-range-slider-inputs { display: flex; flex-direction: column; @@ -204,23 +211,12 @@ .dash-range-slider-min-input { text-align: center; + max-width: 140px; } .dash-range-slider-max-input { order: 1; -} - -.dash-range-slider-input { - min-width: 5cqw; /* 5% of container width */ - max-width: 25cqw; /* 25% of container width */ - text-align: center; - -webkit-appearance: textfield; - -moz-appearance: textfield; - appearance: textfield; - font-family: inherit; - font-size: inherit; - box-sizing: content-box; - height: 30px; + max-width: 140px; } .dash-range-slider-input:only-of-type { @@ -259,3 +255,41 @@ display: none; } } + +.dash-slider-disabled-ranges-container { + position: absolute; + inset: 0; + top: 10%; + left: 0; + width: 100%; + height: 4px; + transform: translateX(5px); + pointer-events: none; + z-index: -1; +} + +.dash-slider-disabled-ranges-container.vertical { + top: 0; + left: 8px; + width: 4px; + height: 100%; + transform: translateY(5px); +} + +.dash-slider-disabled-range { + position: absolute; + background: repeating-linear-gradient( + -45deg, + #dc2626 0px, + #dc2626 2.5px, + transparent 2px, + transparent 5px + ); + opacity: 1; + height: 100%; +} + +.dash-slider-disabled-ranges-container.vertical .dash-slider-disabled-range { + width: 100%; + height: auto; +} diff --git a/components/dash-core-components/src/fragments/DateRangeSlider.tsx b/components/dash-core-components/src/fragments/DateRangeSlider.tsx new file mode 100644 index 0000000000..67d02e6605 --- /dev/null +++ b/components/dash-core-components/src/fragments/DateRangeSlider.tsx @@ -0,0 +1,548 @@ +import React, { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {omit} from 'ramda'; +import DatePickerSingle from '../components/DatePickerSingle'; +import { + DateRangeSliderProps, + PersistedProps, + PersistenceTypes, + RangeSliderProps, +} from '../types'; +import { + dateStringToTimestamp, + timestampToDateString, + strAsDate, + stepDate, + parseDisabledDates, + snapToValidDate, + enforceNoDisabledInBetween, + MS_PER_DAY, + formatDate, + expandDisableFlags, + dateAsStr, + snapToStep, +} from '../utils/calendar/helpers'; +import {autoGenerateDateMarks} from '../utils/computeDateSliderMarkers'; +import rangeSlider from '../utils/LazyLoader/rangeSlider'; + +const RealSlider = lazy(rangeSlider); + +/** + * Slider component for selecting a date. + * This is a wrapper around RangeSlider that handles date-to-timestamp conversions + * and calendar-aware stepping via snapToValidDate(). + */ +export default function DateRangeSlider({ + updatemode = 'mouseup', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persisted_props = [PersistedProps.value], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + persistence_type = PersistenceTypes.local, + // eslint-disable-next-line no-magic-numbers + verticalHeight = 400, + delta_years = 0, + delta_months = 0, + delta_days = 0, + marks, + allow_direct_input = true, + setProps, + min, + max, + value, + drag_value, + disabled_dates, + disable_flags, + display_format, + id, + no_disabled_in_between = false, + vertical = false, + disabled_dates_indicator = true, + ...props +}: DateRangeSliderProps) { + const initialSortedValue = useRef([...(value ?? [])].sort()); + const minStr = min ?? initialSortedValue.current[0]; + const maxStr = + max ?? + initialSortedValue.current[initialSortedValue.current.length - 1]; + + // Convert min/max date strings to timestamps + const mappedMin = useMemo(() => dateStringToTimestamp(minStr), [minStr]); + const mappedMax = useMemo(() => dateStringToTimestamp(maxStr), [maxStr]); + + // Convert min/max date strings to dates + const parsedMin = useMemo(() => strAsDate(minStr), [minStr]); + const parsedMax = useMemo(() => strAsDate(maxStr), [maxStr]); + + // Convert deltas to timestamp + const mappedStep = useMemo(() => { + if (delta_years || delta_months || delta_days) { + const ref = parsedMin ?? new Date(); + const stepped = stepDate( + ref, + `${delta_years}:${delta_months}:${delta_days}` + ); + if (!stepped) { + return undefined; + } + return stepped.getTime() - ref.getTime(); + } + if (marks) { + return null; + } + return MS_PER_DAY; + }, [parsedMin, delta_years, delta_months, delta_days, marks]); + + // String representation of step for use in snapping logic + const step = + delta_years || delta_months || delta_days + ? [delta_years, delta_months, delta_days] + .map(n => Number(n)) + .join(':') + : undefined; + + // Container ref and state for tracking slider width (used in auto-generating marks) + const containerRef = useRef(null); + const [sliderWidth, setSliderWidth] = useState(null); + useEffect(() => { + if (!containerRef.current) { + return; + } + const observer = new ResizeObserver(entries => { + const width = entries[0].contentRect.width; + if (width > 0) { + setSliderWidth(width); + } + }); + observer.observe(containerRef.current); + }, []); + + // Defines what marks are displayed on the slider + const mappedMarks = useMemo(() => { + // Explicit marks, convert date string keys to timestamps + if (marks) { + return Object.entries(marks).reduce< + NonNullable + >((acc, [dateStr, label]) => { + const ts = dateStringToTimestamp(dateStr); + if (typeof ts === 'number') { + acc[ts] = label; + } + return acc; + }, {}); + } + // Has step + if (delta_years || delta_months || delta_days) { + if (!parsedMin || !parsedMax) { + return {}; + } + return autoGenerateDateMarks( + parsedMin, + parsedMax, + step, + display_format, + sliderWidth + ); + } + // No marks, no step + if (!parsedMin || !parsedMax) { + return undefined; + } + return autoGenerateDateMarks( + parsedMin, + parsedMax, + undefined, + display_format, + sliderWidth + ); + }, [ + marks, + parsedMin, + parsedMax, + delta_years, + delta_months, + delta_days, + display_format, + sliderWidth, + ]); + + // Convert date value to timestamp and wrap in array for RangeSlider + const mappedValue: RangeSliderProps['value'] = useMemo(() => { + if (Array.isArray(value)) { + return value + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number'); + } + const timestamp = dateStringToTimestamp(value); + return typeof timestamp === 'number' ? [timestamp] : timestamp; + }, [value]); + + // Convert drag_value to timestamp and wrap in array + const mappedDragValue: RangeSliderProps['drag_value'] = useMemo(() => { + if (Array.isArray(drag_value)) { + return drag_value + .map(dateStringToTimestamp) + .filter((ts): ts is number => typeof ts === 'number'); + } + const timestamp = dateStringToTimestamp(drag_value); + return typeof timestamp === 'number' ? [timestamp] : timestamp; + }, [drag_value]); + + const {parsedDisabledDates, parsedDisabledRanges} = useMemo( + () => parseDisabledDates(disabled_dates), + [disabled_dates] + ); + + // Creates visual indicators for disabled dates/ranges + const disabledIndicators = useMemo(() => { + if (!parsedMin || !parsedMax) { + return []; + } + + // Helper to convert date to percentage position on slider + const minTs = parsedMin.getTime(); + const totalRange = parsedMax.getTime() - minTs; + const toPercent = (ts: number) => + Math.max(0, Math.min(100, ((ts - minTs) / totalRange) * 100)); + const singleWidth = (MS_PER_DAY / totalRange) * 100; + + // Expand disable_flags into individual dates and ranges + const {dates: flagDates, ranges: flagRanges} = disable_flags + ? expandDisableFlags(disable_flags, parsedMin, parsedMax) + : {dates: [], ranges: []}; + + const {parsedDisabledDates: allDates, parsedDisabledRanges: allRanges} = + parseDisabledDates([ + ...(disabled_dates ?? []), + ...flagRanges.map(([s, e]) => [dateAsStr(s)!, dateAsStr(e)!]), + ...flagDates.map(d => dateAsStr(d)!), + ]); + + return [ + ...(allRanges ?? []), + ...(allDates?.map(d => [d, d] as [Date, Date]) ?? []), + ].map(([start, end], index) => { + // Calculate position and size of disabled range indicator + const startPercent = toPercent(start.getTime()); + const endPercent = toPercent(end.getTime()); + const margin = singleWidth / 2; + + // Margin to make single-day disables visible + const position = Math.max(0, startPercent - margin); + const size = Math.min(100, endPercent + margin) - position; + + // Disabled range indicators render + return ( +
+ ); + }); + }, [disabled_dates, disable_flags, parsedMin, parsedMax, vertical]); + + // Forces slider to reset to current value when marks change + const [resetKey, setResetKey] = useState(0); + + const mappedValueRef = useRef(mappedValue); + useEffect(() => { + mappedValueRef.current = mappedValue; + }, [mappedValue]); + + const prevDateValueRef = useRef(value ?? undefined); + + // Converts what comes back from the RangeSlider (timestamps) into date strings to show the user + const mappedSetProps: RangeSliderProps['setProps'] = useCallback( + newProps => { + const {value, drag_value} = newProps; + const mappedProps: Partial = omit( + ['min', 'max', 'step', 'value', 'drag_value', 'setProps'], + newProps + ); + if ('min' in newProps) { + mappedProps.min = timestampToDateString(newProps.min); + } + if ('max' in newProps) { + mappedProps.max = timestampToDateString(newProps.max); + } + if ('value' in newProps && value) { + // Convert slider timestamps to date strings + let rawDates = value + .map(raw => timestampToDateString(raw)) + .filter((v): v is string => v !== undefined); + + // If no_disabled_in_between enabled, prevents selection from crossing disabled dates + const prev = prevDateValueRef.current; + if ( + no_disabled_in_between && + rawDates.length === 2 && + prev?.length === 2 + ) { + rawDates = enforceNoDisabledInBetween( + rawDates as [string, string], + prev as [string, string], + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + step + ); + } + + // Snap each date to avoid disabled dates and align to step + const snappedDates = rawDates + .map(dateStr => { + const r = strAsDate(dateStr); + if (!r) { + return undefined; + } + return timestampToDateString( + snapToValidDate( + r, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags + ).getTime() + ); + }) + .filter((v): v is string => v !== undefined); + + // Update the value + mappedProps.value = snappedDates; + prevDateValueRef.current = snappedDates; + + // Check if value changed after snapping + const snappedTs = snappedDates.map(dateStringToTimestamp); + const noChange = snappedTs.every( + (ts, i) => ts === mappedValueRef.current?.[i] + ); + // Reset slider to acknowledge interaction if no change happened + if (noChange) { + setResetKey(k => k + 1); + } + } + if ('drag_value' in newProps && drag_value) { + mappedProps.drag_value = drag_value + .map(raw => timestampToDateString(raw)) + .filter((v): v is string => v !== undefined); + } + setProps(mappedProps); + }, + [setProps] + ); + + // Timestamp to date conversion, respects display_format for tooltip and direct input + useMemo(() => { + const formatFuncName = `dateRangeSliderFormatDate_${id || 'default'}`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const win = window as Record; + win.dccFunctions = win.dccFunctions || {}; + + // Register the formatter function globally + win.dccFunctions[formatFuncName] = (timestamp: number) => { + try { + const date = new Date(timestamp); + // Snap tooltip dates to step and valid dates to avoid showing disabled dates in tooltip + const snapped = snapToStep( + date, + parsedMin ?? date, + step ?? '0:0:1' + ); + return formatDate(snapped, display_format || 'DD-MM-YYYY'); + } catch (err) { + return `${timestamp}`; + } + }; + }, [ + display_format, + id, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + ]); + + // Display dates in tooltip using the formatter function + const customTooltip = useMemo(() => { + const formatFuncName = `dateRangeSliderFormatDate_${id || 'default'}`; + const baseTooltip = props.tooltip || { + placement: 'top', + always_visible: false, + }; + return { + ...baseTooltip, + template: '{value}', + transform: formatFuncName, + }; + }, [id, props.tooltip]); + + // Format values for display in custom inputs, compatible with date picker and display_format + const displayValues = useMemo< + [ + `${string}-${string}-${string}` | undefined, + `${string}-${string}-${string}` | undefined + ] + >(() => { + if (Array.isArray(value)) { + return [ + (value[0] as `${string}-${string}-${string}`) || undefined, + (value[1] as `${string}-${string}-${string}`) || undefined, + ]; + } + return [undefined, undefined]; + }, [value]); + + // Handle input changes for date inputs + const handleDateInputChange = useCallback( + (index: number, dateStr: string) => { + const newValue = [...(Array.isArray(value) ? value : ['', ''])]; + + // If input is cleared, reset to min or max + if (!dateStr) { + newValue[index] = index === 0 ? minStr || '' : maxStr || ''; + setProps({value: newValue}); + return; + } + + // Parse input date string + const inputDate = strAsDate(dateStr); + if (!inputDate) { + return; + } + + // Snap to valid date, avoiding disabled dates and respecting step + const snappedDate = snapToValidDate( + inputDate, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags + ); + + // Convert snapped date back to string for comparison and state update + const snappedDateStr = dateAsStr(snappedDate) || ''; + + // Check if the snapped date matches exactly what we already have in state + const hasNoChange = + Array.isArray(value) && value[index] === snappedDateStr; + + if (hasNoChange) { + // ensures input corresponds to the snapped date even if user types a different but equivalent date + setResetKey(k => k + 1); + } else { + // Update the value with the snapped date string + newValue[index] = snappedDateStr; + setProps({value: newValue}); + } + }, + [ + value, + setProps, + minStr, + maxStr, + step, + parsedMin, + parsedMax, + parsedDisabledDates, + parsedDisabledRanges, + disable_flags, + ] + ); + + return ( +
+ {allow_direct_input && + Array.isArray(value) && + value.length === 2 && ( + + handleDateInputChange(0, date || '') + } + placeholder="Start date" + min_date_allowed={minStr} + max_date_allowed={maxStr} + display_format={display_format} + /> + )} +
+ + + + {disabled_dates_indicator && disabledIndicators.length > 0 && ( +
+ {disabledIndicators} +
+ )} +
+ {allow_direct_input && + Array.isArray(value) && + value.length === 2 && ( + + handleDateInputChange(1, date || '') + } + placeholder="End date" + min_date_allowed={minStr} + max_date_allowed={maxStr} + display_format={display_format} + /> + )} +
+ ); +} + +DateRangeSlider.dashPersistence = { + persisted_props: [PersistedProps.value], + persistence_type: PersistenceTypes.local, +}; diff --git a/components/dash-core-components/src/index.ts b/components/dash-core-components/src/index.ts index a2555149d4..2b5e037535 100644 --- a/components/dash-core-components/src/index.ts +++ b/components/dash-core-components/src/index.ts @@ -19,6 +19,8 @@ import Markdown from './components/Markdown.react'; import RadioItems from './components/RadioItems'; import RangeSlider from './components/RangeSlider'; import Slider from './components/Slider'; +import DateRangeSlider from './components/DateRangeSlider'; +import DateSlider from './components/DateSlider'; import Store from './components/Store.react'; import Tab from './components/Tab'; import Tabs from './components/Tabs'; @@ -49,6 +51,8 @@ export { RadioItems, RangeSlider, Slider, + DateRangeSlider, + DateSlider, Store, Tab, Tabs, diff --git a/components/dash-core-components/src/types.ts b/components/dash-core-components/src/types.ts index f4bc430141..0b39a8ba66 100644 --- a/components/dash-core-components/src/types.ts +++ b/components/dash-core-components/src/types.ts @@ -618,6 +618,284 @@ export interface RangeSliderProps extends BaseDccProps { allow_direct_input?: boolean; } +export type DateSliderMarks = { + [key: string]: string | {label: string; style?: React.CSSProperties}; +}; + +export type DisableDatesFlag = + | 'weekends' + | 'weekdays' + | 'sundays' + | 'mondays' + | 'tuesdays' + | 'wednesdays' + | 'thursdays' + | 'fridays'; +// | holidays; + +export interface DateSliderProps extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Number of years to increment per step. + */ + delta_years?: number | null; + + /** + * Number of months to increment per step. + */ + delta_months?: number | null; + + /** + * Number of days to increment per step. + */ + delta_days?: number | null; + + /** + * Marks on the slider. + * The key determines the position (a string for date), + * and the value determines what will show. + * If you want to set the style of a specific mark point, + * the value should be an object which + * contains style and label properties. + */ + marks?: DateSliderMarks | null; + + /** + * The date value of the input (Date string format) + */ + value?: string | null; + + /** + * The date value of the input during a drag (Date string format) + */ + drag_value?: string; + + /** + * If true, the handles can't be moved. + */ + disabled?: boolean; + + /** + * When the step value is greater than 1, + * you can set the dots to true if you want to + * render the slider with dots. + */ + dots?: boolean; + + /** + * If the value is true, it means a continuous + * value is included. Otherwise, it is an independent value. + */ + included?: boolean; + + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + + /** + * Configuration for tooltips describing the current slider value + */ + tooltip?: SliderTooltip; + + /** + * Determines when the component should update its `value` + * property. If `mouseup` (the default) then the slider + * will only trigger its value when the user has finished + * dragging the slider. If `drag`, then the slider will + * update its value continuously as it is being dragged. + * If you want different actions during and after drag, + * leave `updatemode` as `mouseup` and use `drag_value` + * for the continuously updating value. + */ + updatemode?: 'mouseup' | 'drag'; + + /** + * If true, the slider will be vertical + */ + vertical?: boolean; + + /** + * The height, in px, of the slider if it is vertical. + */ + verticalHeight?: number; + + /** + * If false, the input elements for directly entering values will be hidden. + * Only the slider will be visible and it will occupy 100% width of the container. + */ + allow_direct_input?: boolean; + + /** + * An array of disabled dates. Can be an array of specific dates (strings) + * or an array of date ranges (arrays of two date strings). + */ + disabled_dates?: (string | string[])[]; + + /** + * Specific flags to disable certain types of dates. + * Can be a single flag or an array of flags. + */ + disable_flags?: DisableDatesFlag[]; + + /** + * A string specifying the format in which the date should be displayed. + * (e.g. 'YYYY-MM-DD', 'MM/DD/YYYY', etc.) + */ + display_format?: string; + + /** + * If true, the date or range of dates that are disabled will be shown by + * red stripes on the slider, and users will not be able to select those dates. + */ + disabled_dates_indicator?: boolean; +} + +export interface DateRangeSliderProps + extends BaseDccProps { + /** + * Minimum allowed date + */ + min?: string; + + /** + * Maximum allowed date + */ + max?: string; + + /** + * Number of years to increment per step. + */ + delta_years?: number | null; + + /** + * Number of months to increment per step. + */ + delta_months?: number | null; + + /** + * Number of days to increment per step. + */ + delta_days?: number | null; + + /** + * Marks on the slider. + * The key determines the position (a number), + * and the value determines what will show. + * If you want to set the style of a specific mark point, + * the value should be an object which + * contains style and label properties. + */ + marks?: DateSliderMarks | null; + + /** + * The date value of the input (Date string format) + */ + value?: string[] | null; + + /** + * The date value of the input during a drag (Date string format) + */ + drag_value?: string[]; + + /** + * If true, the handles can't be moved. + */ + disabled?: boolean; + + /** + * When the step value is greater than 1, + * you can set the dots to true if you want to + * render the slider with dots. + */ + dots?: boolean; + + /** + * If the value is true, it means a continuous + * value is included. Otherwise, it is an independent value. + */ + included?: boolean; + + /** + * If the value is true, the slider is rendered in reverse. + */ + reverse?: boolean; + + /** + * Configuration for tooltips describing the current slider value + */ + tooltip?: SliderTooltip; + + /** + * Determines when the component should update its `value` + * property. If `mouseup` (the default) then the slider + * will only trigger its value when the user has finished + * dragging the slider. If `drag`, then the slider will + * update its value continuously as it is being dragged. + * If you want different actions during and after drag, + * leave `updatemode` as `mouseup` and use `drag_value` + * for the continuously updating value. + */ + updatemode?: 'mouseup' | 'drag'; + + /** + * If true, the slider will be vertical + */ + vertical?: boolean; + + /** + * The height, in px, of the slider if it is vertical. + */ + verticalHeight?: number; + + /** + * If false, the input elements for directly entering values will be hidden. + * Only the slider will be visible and it will occupy 100% width of the container. + */ + allow_direct_input?: boolean; + + /** + * An array of disabled dates. Can be an array of specific dates (strings) + * or an array of date ranges (arrays of two date strings). + */ + disabled_dates?: (string | string[])[]; + + /** + * Specific flags to disable certain types of dates. + * Can be a single flag or an array of flags. + */ + disable_flags?: DisableDatesFlag[]; + + /** + * A string specifying the format in which the date should be displayed. + * (e.g. 'YYYY-MM-DD', 'MM/DD/YYYY', etc.) + */ + display_format?: string; + + /** + * If true, the selected range will automatically clamp to exclude any + * disabled dates when the range is expanded. The boundary closest to + * the disabled date will be adjusted to stop just before it. + * Requires `value` to have exactly two dates (a start and end). + */ + no_disabled_in_between?: boolean; + + /** + * True by default, the date or range of dates that are disabled will be shown by + * red stripes on the slider, and users will not be able to select those dates. + */ + disabled_dates_indicator?: boolean; +} + export type OptionValue = string | number | boolean; export type DetailedOption = { diff --git a/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts b/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts new file mode 100644 index 0000000000..c4febc7706 --- /dev/null +++ b/components/dash-core-components/src/utils/LazyLoader/dateRangeSlider.ts @@ -0,0 +1,2 @@ +export default () => + import(/* webpackChunkName: "slider" */ '../../fragments/DateRangeSlider'); diff --git a/components/dash-core-components/src/utils/calendar/helpers.ts b/components/dash-core-components/src/utils/calendar/helpers.ts index 7fb7ea89d9..1c3a83c75c 100644 --- a/components/dash-core-components/src/utils/calendar/helpers.ts +++ b/components/dash-core-components/src/utils/calendar/helpers.ts @@ -9,11 +9,31 @@ import { isBefore, isAfter, isWithinInterval, + isWeekend, min, max, + addYears, + addMonths, + addDays, + getDay, + differenceInDays, } from 'date-fns'; import type {Locale} from 'date-fns'; import {DatePickerSingleProps} from '../../types'; +// import Holidays from 'date-holidays'; + +// Conversão para timestamps, deixei global depois posso trocar +// Tenho de separar por causa do lint check +const HOURS_PER_DAY = 24; +const MINUTES_PER_HOUR = 60; +const SECONDS_PER_MINUTE = 60; +const MILLISECONDS_PER_SECOND = 1000; +export const MS_PER_DAY = + HOURS_PER_DAY * + MINUTES_PER_HOUR * + SECONDS_PER_MINUTE * + MILLISECONDS_PER_SECOND; +const EPOCH = new Date(0); declare global { interface Window { @@ -71,6 +91,25 @@ export function getUserLocale(): Locale | undefined { return availableLocales[localeKeys[0]]; } +/** + * Infers the user's country from their browser language preferences. + * Extracts the region subtag from locale strings (e.g. 'en-US' → 'US'). + * Reflects browser language settings, not the user's actual location. + */ +export function getUserCountry(): string { + const userLanguages = navigator.languages || [navigator.language]; + for (const lang of userLanguages) { + // e.g. 'pt-PT' → 'PT', 'en-US' → 'US' + const parts = lang.split('-'); + if (parts.length > 1) { + return parts[1].toUpperCase(); + } + } + + // No region subtag found, fall back to US + return 'US'; +} + export function formatDate(date?: Date, formatStr = 'YYYY-MM-DD'): string { if (!date) { return ''; @@ -162,14 +201,71 @@ export function isDateInRange( return true; } +export type DisablePredicate = (date: Date) => boolean; +export type DisableFlag = + | 'weekends' + | 'weekdays' + | 'mondays' + | 'tuesdays' + | 'wednesdays' + | 'thursdays' + | 'fridays' + | 'saturdays' + | 'sundays'; +// | 'holidays'; + +const DAY_FLAGS: Partial> = { + sundays: 0, + mondays: 1, + tuesdays: 2, + wednesdays: 3, + thursdays: 4, + fridays: 5, + saturdays: 6, +}; + +/** + * Converts a YYYY-MM-DD date string to milliseconds. + * Always treats dates as UTC to avoid timezone shifts. + */ +export function dateStringToTimestamp( + dateStr: string | null | undefined +): number | undefined { + if (!dateStr) { + return undefined; + } + const [year, month, day] = dateStr.split('-').map(Number); + return Date.UTC(year, month - 1, day); +} + +/** + * Converts milliseconds since epoch to a YYYY-MM-DD date string. + * Always treats dates as UTC to avoid timezone shifts. + */ +export function timestampToDateString( + timestamp: number | undefined +): string | undefined { + if (!timestamp) { + return undefined; + } + const days = Math.round(timestamp / MS_PER_DAY); + return new Date(days * MS_PER_DAY).toISOString().split('T')[0]; +} + /** - * Checks if a date is disabled based on min/max constraints and disabled dates array. + * Checks if a date is disabled based on min/max constraints, disabled dates array, + * and optional disable flags or custom predicates. */ export function isDateDisabled( date: Date, minDate?: Date, maxDate?: Date, - disabledDates?: Date[] + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array ): boolean { // Check if date is outside min/max range if (!isDateInRange(date, minDate, maxDate)) { @@ -177,8 +273,35 @@ export function isDateDisabled( } // Check if date is in the disabled dates array - if (disabledDates) { - return disabledDates.some(d => isSameDay(date, d)); + if (disabledDates?.some(d => isSameDay(date, d))) { + return true; + } + + // Check if date is in a disabled range + if ( + disabledRanges?.some(([start, end]) => isDateInRange(date, start, end)) + ) { + return true; + } + + // Check if date matches a given flag/predicate + if (disableFlags) { + const flags = Array.isArray(disableFlags) + ? disableFlags + : [disableFlags]; + return flags.some(flag => { + if (typeof flag === 'function') { + return flag(date); + } + switch (flag) { + case 'weekends': + return isWeekend(date); + case 'weekdays': + return !isWeekend(date); + default: + return getDay(date) === (DAY_FLAGS[flag] ?? -1); + } + }); } return false; @@ -271,3 +394,472 @@ export function parseYear(yearStr: string): number | undefined { } return undefined; } + +/** + * Returns the next date after applying a "years:months:days" step to a start date. + */ +export function stepDate(date?: Date, step?: string): Date | undefined { + if (!date || !step) { + return undefined; + } + + const parts = step.split(':').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + return undefined; + } + + const [years, months, days] = parts; + + let result = date; + if (years) { + result = addYears(result, years); + } + if (months) { + result = addMonths(result, months); + } + if (days) { + result = addDays(result, days); + } + + return result; +} + +/** + * Merges overlapping date ranges into a minimal set of non-overlapping ranges. + * Assumes ranges are inclusive. + * Example: [[1 Jan, 5 Jan], [3 Jan, 10 Jan]] becomes [[1 Jan, 10 Jan]] + */ +function mergeRanges(ranges: [Date, Date][]): [Date, Date][] { + if (ranges.length === 0) { + return []; + } + + const sorted = [...ranges].sort((a, b) => a[0].getTime() - b[0].getTime()); + const merged: [Date, Date][] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const [currentStart, currentEnd] = sorted[i]; + const [, lastEnd] = merged[merged.length - 1]; + + if (currentStart <= lastEnd) { + merged[merged.length - 1][1] = + currentEnd > lastEnd ? currentEnd : lastEnd; + } else { + merged.push([currentStart, currentEnd]); + } + } + return merged; +} + +/** + * Parses and separates disabled date entries into individual dates and ranges. + * Overlapping ranges are automatically merged. + */ +export function parseDisabledDates(disabled_dates?: (string | string[])[]): { + parsedDisabledDates?: Date[]; + parsedDisabledRanges?: [Date, Date][]; +} { + if (!disabled_dates) { + return {}; + } + + const dates: Date[] = []; + const ranges: [Date, Date][] = []; + + for (const entry of disabled_dates) { + if (Array.isArray(entry)) { + const start = strAsDate(entry[0]); + const end = strAsDate(entry[1]); + if (start && end) { + ranges.push([start, end]); + } + } else { + const date = strAsDate(entry); + if (date) { + dates.push(date); + } + } + } + const mergedRanges = ranges.length > 0 ? mergeRanges(ranges) : undefined; + const filteredDates = dates.filter( + date => + !mergedRanges?.some(([start, end]) => + isDateInRange(date, start, end) + ) + ); + return { + parsedDisabledDates: + filteredDates.length > 0 ? filteredDates : undefined, + parsedDisabledRanges: mergedRanges, + }; +} + +export function expandDisableFlags( + flags: + | DisableFlag + | DisablePredicate + | Array, + minDate: Date, + maxDate: Date +): {dates: Date[]; ranges: [Date, Date][]} { + const disabled: Date[] = []; + for (let d = startOfDay(minDate); d <= maxDate; d = addDays(d, 1)) { + if ( + isDateDisabled(d, undefined, undefined, undefined, undefined, flags) + ) { + disabled.push(d); + } + } + + const groups: Date[][] = []; + for (const d of disabled) { + const last = groups[groups.length - 1]; + if ( + last && + addDays(last[last.length - 1], 1).getTime() === d.getTime() + ) { + last.push(d); + } else { + groups.push([d]); + } + } + + const dates: Date[] = []; + const ranges: [Date, Date][] = []; + for (const group of groups) { + if (group.length > 1) { + ranges.push([group[0], group[group.length - 1]]); + } else { + dates.push(group[0]); + } + } + + return {dates, ranges}; +} + +/** + * Finds the nearest valid date according to min/max bounds, + * disabled dates, disabled ranges, and disable flags. + * If the provided date is already valid, it is returned unchanged. + * When inside a disabled range, the function snaps to the closest + * valid boundary outside that range. + * Otherwise, the function searches incrementally forward/backward + * for the nearest valid date. + */ +export function snapToValidDate( + date: Date, + step?: string, + minDate?: Date, + maxDate?: Date, + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array +): Date { + const MAX_SEARCH = 1000; + + const gridDate = step ? snapToStep(date, minDate ?? date, step) : date; + + if ( + !isDateDisabled( + gridDate, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return gridDate; + } + + const backStep = + step + ?.split(':') + .map(n => String(-Number(n))) + .join(':') ?? '0:0:-1'; + const fwdStep = step ?? '0:0:1'; + const anchor = minDate ?? date; + + const walkToValid = ( + candidate: Date, + direction: 'before' | 'after' + ): Date | undefined => { + const dirStep = direction === 'before' ? backStep : fwdStep; + const boundOk = (d: Date) => + direction === 'before' + ? !minDate || d >= minDate + : !maxDate || d <= maxDate; + let d = step ? snapToStep(candidate, anchor, step) : candidate; + if (direction === 'before' && d > candidate) { + d = stepDate(d, backStep) ?? d; + } + if (direction === 'after' && d < candidate) { + d = stepDate(d, fwdStep) ?? d; + } + for (let i = 0; i < MAX_SEARCH; i++) { + if ( + !isDateDisabled( + d, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) && + boundOk(d) + ) { + return d; + } + const next = stepDate(d, dirStep); + if (!next) { + break; + } + d = next; + } + return undefined; + }; + + const containingRange = disabledRanges?.find(([start, end]) => + isDateInRange(gridDate, start, end) + ); + if (containingRange) { + const [start, end] = containingRange; + const gridAnchor = minDate ?? date; + const firstAfter = (() => { + const snapped = snapToStep(end, gridAnchor, fwdStep); + return snapped > end + ? snapped + : stepDate(snapped, fwdStep) ?? undefined; + })(); + const firstBefore = (() => { + const snapped = snapToStep(start, gridAnchor, step ?? '0:0:1'); + return snapped < start + ? snapped + : stepDate(snapped, backStep) ?? undefined; + })(); + const validBefore = firstBefore + ? walkToValid(firstBefore, 'before') + : undefined; + const validAfter = firstAfter + ? walkToValid(firstAfter, 'after') + : undefined; + if (validBefore && validAfter) { + const distBefore = Math.abs( + differenceInDays(gridDate, validBefore) + ); + const distAfter = Math.abs(differenceInDays(gridDate, validAfter)); + return distBefore <= distAfter ? validBefore : validAfter; + } + return validBefore ?? validAfter ?? gridDate; + } + + const forward = walkToValid(gridDate, 'after'); + const backward = walkToValid(gridDate, 'before'); + if (forward && backward) { + const distForward = Math.abs(differenceInDays(gridDate, forward)); + const distBackward = Math.abs(differenceInDays(gridDate, backward)); + return distForward <= distBackward ? forward : backward; + } + return backward ?? forward ?? gridDate; +} + +/** + * Snaps a date to the nearest valid step interval relative to an anchor date. + * The step format is "years:months:days". + * + * Example: + * anchor = 2025-01-01 + * step = "0:0:7" + * + * Valid snapped dates: + * 2025-01-01, 2025-01-08, 2025-01-15, ... + */ +export function snapToStep(date: Date, anchor: Date, step: string): Date { + const MAX_SEARCH = 1000; + + if (!step) { + return date; + } + + let prev = anchor; + let next = stepDate(anchor, step); + + if (!next) { + return date; + } + + if (date < anchor) { + const negStep = step + .split(':') + .map(n => -Number(n)) + .join(':'); + prev = anchor; + next = stepDate(anchor, negStep) ?? anchor; + for (let i = 0; next > date && i < MAX_SEARCH; i++) { + prev = next; + const stepped = stepDate(next, negStep); + if (!stepped) { + break; + } + next = stepped; + } + const distPrev = Math.abs(differenceInDays(date, prev)); + const distNext = Math.abs(differenceInDays(date, next)); + const result = distNext <= distPrev ? next : prev; + + return result; + } + for (let i = 0; next < date && i < MAX_SEARCH; i++) { + prev = next; + const stepped = stepDate(next, step); + if (!stepped) { + break; + } + next = stepped; + } + const distPrev = Math.abs(differenceInDays(date, prev)); + const distNext = Math.abs(differenceInDays(date, next)); + const result = distPrev <= distNext ? prev : next; + + return result; +} + +/** + * Ensures a range expansion does not cross disabled constraints. + * The expanded side is preserved and the opposite side is pulled + * inward minimally to avoid disabled intersections. + */ +export function enforceNoDisabledInBetween( + newDates: [string, string], + prevDates: [string, string], + minDate?: Date, + maxDate?: Date, + disabledDates?: Date[], + disabledRanges?: [Date, Date][], + disableFlags?: + | DisableFlag + | DisablePredicate + | Array, + step?: string +): [string, string] { + const forward = step ?? '0:0:1'; + const backward = step + ? step + .split(':') + .map(n => String(-Number(n))) + .join(':') + : '0:0:-1'; + + const [newLeftStr, newRightStr] = newDates; + const [prevLeftStr, prevRightStr] = prevDates; + + const newLeft = strAsDate(newLeftStr); + const newRight = strAsDate(newRightStr); + const prevLeft = strAsDate(prevLeftStr); + const prevRight = strAsDate(prevRightStr); + + if (!newLeft || !newRight || !prevLeft || !prevRight) { + return newDates; + } + const leftChanged = newLeft < prevLeft || newLeft > prevLeft; + const rightChanged = newRight < prevRight || newRight > prevRight; + + if (!leftChanged && !rightChanged) { + return newDates; + } + const walkFlags = (start: Date, end: Date, dir: string): Date => { + if ( + !disableFlags || + (Array.isArray(disableFlags) && disableFlags.length === 0) + ) { + return end; + } + let d: Date | undefined = start; + while (d && (dir === forward ? d < end : d > end)) { + if ( + isDateDisabled( + d, + undefined, + undefined, + undefined, + undefined, + disableFlags + ) + ) { + return d; + } + d = stepDate(d, dir); + } + return end; + }; + + if (leftChanged) { + if ( + isDateDisabled( + newLeft, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return [newLeftStr, newLeftStr]; + } + let clampRight = stepDate(newRight, forward) ?? newRight; + for (const candidate of [ + ...(disabledRanges?.map(([start]) => start) ?? []), + ...(disabledDates ?? []), + ]) { + if ( + candidate > newLeft && + candidate <= newRight && + candidate < clampRight + ) { + clampRight = candidate; + } + } + clampRight = walkFlags(newLeft, clampRight, forward); + clampRight = stepDate(clampRight, backward) ?? clampRight; + if (clampRight < newLeft) { + return [newLeftStr, newLeftStr]; + } + const rightStr = timestampToDateString(clampRight.getTime()); + return rightStr ? [newLeftStr, rightStr] : newDates; + } + if ( + isDateDisabled( + newRight, + minDate, + maxDate, + disabledDates, + disabledRanges, + disableFlags + ) + ) { + return [newRightStr, newRightStr]; + } + let clampLeft = stepDate(newLeft, backward) ?? newLeft; + for (const candidate of [ + ...(disabledRanges?.map(([, end]) => end) ?? []), + ...(disabledDates ?? []), + ]) { + if ( + candidate < newRight && + candidate >= newLeft && + candidate > clampLeft + ) { + clampLeft = candidate; + } + } + clampLeft = walkFlags(newRight, clampLeft, backward); + clampLeft = stepDate(clampLeft, forward) ?? clampLeft; + if (clampLeft > newRight) { + return [newRightStr, newRightStr]; + } + const leftStr = timestampToDateString(clampLeft.getTime()); + return leftStr ? [leftStr, newRightStr] : newDates; +} diff --git a/components/dash-core-components/src/utils/computeDateSliderMarkers.ts b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts new file mode 100644 index 0000000000..598f4a9129 --- /dev/null +++ b/components/dash-core-components/src/utils/computeDateSliderMarkers.ts @@ -0,0 +1,87 @@ +/* eslint-disable no-magic-numbers */ +import {formatDate, stepDate} from './calendar/helpers'; +import {SliderMarks} from '../types'; + +/** Selects a subset of dates that fit the slider width without label overlap. */ +const estimateBestDateCount = ( + dates: Date[], + formatStr?: string, + sliderWidth?: number | null +): Date[] => { + if (dates.length <= 2) { + return dates; + } + + // Estimate label pixel width from a sample formatted date to avoid overlap + const effectiveWidth = sliderWidth || 330; + const sampleLabel = formatDate(dates[0], formatStr); + const maxLabelChars = sampleLabel.length; + + // Calculate required spacing based on label width + // Estimate: 10px per character + 20px margin for spacing between labels + // This provides comfortable spacing to prevent overlap + const pixelsPerChar = 10; + const spacingMargin = 20; + const minPixelsPerMark = maxLabelChars * pixelsPerChar + spacingMargin; + + const targetCount = Math.max( + 2, + Math.floor(effectiveWidth / minPixelsPerMark) + ); + if (targetCount >= dates.length) { + return dates; + } + const result: Date[] = []; + for (let i = 0; i < targetCount; i++) { + const idx = Math.round((i * (dates.length - 1)) / (targetCount - 1)); + result.push(dates[idx]); + } + return result; +}; + +const toUtcTs = (date: Date) => { + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); +}; + +/** Generates slider marks for a date-based slider, mirroring autoGenerateMarks. */ +export const autoGenerateDateMarks = ( + minDate: Date, + maxDate: Date, + step?: string, + formatStr?: string, + sliderWidth?: number | null +) => { + const minTs = toUtcTs(minDate); + const maxTs = toUtcTs(maxDate); + const s = step ? step : '0:0:1'; + + // Iterate through all valid step positions between min and max + const allDates: Date[] = []; + let cursor = minDate; + while (cursor.getTime() <= maxTs) { + allDates.push(cursor); + const next = stepDate(cursor, s); + if (!next) { + break; + } + cursor = next; + } + + // Filter to a visible subset that fits without overlapping + const visibleDates = estimateBestDateCount( + allDates, + formatStr, + sliderWidth + ); + + const dateMarks: SliderMarks = {}; + visibleDates.forEach(date => { + dateMarks[toUtcTs(date)] = formatDate(date, formatStr); + }); + + // Always include min and max regardless of density filtering + dateMarks[minTs] = formatDate(minDate, formatStr); + dateMarks[maxTs] = formatDate(maxDate, formatStr); + + return dateMarks; +}; diff --git a/components/dash-core-components/tests/unit/calendar/helpers.test.ts b/components/dash-core-components/tests/unit/calendar/helpers.test.ts index 11775ab54c..a46f0358e5 100644 --- a/components/dash-core-components/tests/unit/calendar/helpers.test.ts +++ b/components/dash-core-components/tests/unit/calendar/helpers.test.ts @@ -1,6 +1,7 @@ import { dateAsStr, isDateInRange, + isDateDisabled, strAsDate, formatDate, formatMonth, @@ -8,6 +9,12 @@ import { getMonthOptions, formatYear, parseYear, + stepDate, + parseDisabledDates, + expandDisableFlags, + snapToValidDate, + snapToStep, + enforceNoDisabledInBetween, } from '../../../src/utils/calendar/helpers'; describe('strAsDate and dateAsStr', () => { @@ -276,3 +283,922 @@ describe('parseYear', () => { expect(parseYear(' 97 ')).toBe(1997); }); }); + +describe('stepDate', () => { + const baseDate = new Date(2026, 4, 4); // May 4, 2026 + + it('applies years, months, and days together', () => { + const cases: Array<[string, Date]> = [ + ['1:2:3', new Date(2027, 6, 7)], + ['0:0:1', new Date(2026, 4, 5)], + ['1:0:0', new Date(2027, 4, 4)], + ['0:1:0', new Date(2026, 5, 4)], + ]; + + for (const [step, expected] of cases) { + expect(stepDate(baseDate, step)).toEqual(expected); + } + }); + + it('handles zero step components (no-op parts)', () => { + expect(stepDate(baseDate, '0:0:0')).toEqual(baseDate); + }); + + it('handles month-end overflow correctly', () => { + const may31 = new Date(2026, 4, 31); + expect(stepDate(may31, '0:1:0')).toEqual(new Date(2026, 5, 30)); + }); + + it('handles leap year transitions', () => { + const feb29 = new Date(2024, 1, 29); + expect(stepDate(feb29, '1:0:0')).toEqual(new Date(2025, 1, 28)); + }); + + it('returns undefined for missing date or step', () => { + expect(stepDate(undefined, '1:0:0')).toBeUndefined(); + expect(stepDate(baseDate, undefined)).toBeUndefined(); + expect(stepDate(undefined, undefined)).toBeUndefined(); + }); + + it('returns undefined for malformed step strings', () => { + const invalidSteps = ['1:2', '1:2:3:4', 'a:b:c', '', '1:x:0']; + + for (const step of invalidSteps) { + expect(stepDate(baseDate, step)).toBeUndefined(); + } + }); +}); + +describe('isDateDisabled', () => { + const days = { + monday: new Date(2026, 4, 4), + tuesday: new Date(2026, 4, 5), + wednesday: new Date(2026, 4, 6), + thursday: new Date(2026, 4, 7), + friday: new Date(2026, 4, 8), + saturday: new Date(2026, 4, 9), + sunday: new Date(2026, 4, 10), + }; + + it('disables dates outside min/max range', () => { + const min = new Date(2026, 4, 8); + const max = new Date(2026, 4, 18); + expect(isDateDisabled(new Date(2026, 4, 5), min, max)).toBe(true); + expect(isDateDisabled(new Date(2026, 4, 7), min, max)).toBe(true); + + expect(isDateDisabled(new Date(2026, 4, 8), min, max)).toBe(false); + expect(isDateDisabled(new Date(2026, 4, 10), min, max)).toBe(false); + expect(isDateDisabled(new Date(2026, 4, 18), min, max)).toBe(false); + + expect(isDateDisabled(new Date(2026, 4, 19), min, max)).toBe(true); + expect(isDateDisabled(new Date(2026, 4, 21), min, max)).toBe(true); + }); + + it('disables specific dates from array', () => { + const disabled = [new Date(2026, 4, 15), new Date(2026, 4, 20)]; + + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + disabled + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 16), + undefined, + undefined, + disabled + ) + ).toBe(false); + }); + + it('handles weekend and weekday flags', () => { + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(true); + expect( + isDateDisabled( + days.sunday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ) + ).toBe(false); + + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + 'weekdays' + ) + ).toBe(true); + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + 'weekdays' + ) + ).toBe(false); + }); + + it('handles individual day-of-week flags', () => { + const cases: Array<[string, Date]> = [ + ['mondays', days.monday], + ['tuesdays', days.tuesday], + ['wednesdays', days.wednesday], + ['thursdays', days.thursday], + ['fridays', days.friday], + ['saturdays', days.saturday], + ['sundays', days.sunday], + ]; + + for (const [flag, targetDay] of cases) { + const otherDay = flag === 'mondays' ? days.tuesday : days.monday; + + expect( + isDateDisabled( + targetDay, + undefined, + undefined, + undefined, + undefined, + flag as any + ) + ).toBe(true); + expect( + isDateDisabled( + otherDay, + undefined, + undefined, + undefined, + undefined, + flag as any + ) + ).toBe(false); + } + }); + + it('handles array of flags', () => { + const flags = ['mondays', 'wednesdays', 'fridays'] as any; + + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.wednesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.friday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.tuesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('combines weekend flag with individual day flag', () => { + const flags = ['weekends', 'mondays'] as any; + + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.sunday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.tuesday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('supports custom predicate function', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + undefined, + undefined, + isThe15th + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 16), + undefined, + undefined, + undefined, + undefined, + isThe15th + ) + ).toBe(false); + }); + + it('combines predicate with flags', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + const flags = ['weekends', isThe15th] as any; + + expect( + isDateDisabled( + days.saturday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + new Date(2026, 4, 15), + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(true); + expect( + isDateDisabled( + days.monday, + undefined, + undefined, + undefined, + undefined, + flags + ) + ).toBe(false); + }); + + it('returns false when no constraints are set', () => { + expect(isDateDisabled(days.monday)).toBe(false); + expect(isDateDisabled(days.sunday)).toBe(false); + }); +}); + +describe('parseDisabledDates', () => { + it('returns empty object for undefined input', () => { + expect(parseDisabledDates(undefined)).toEqual({}); + }); + + it('parses individual date strings into Date objects', () => { + const result = parseDisabledDates(['2026-01-15', '2026-03-20']); + expect(result.parsedDisabledDates).toHaveLength(2); + expect(result.parsedDisabledDates![0]).toEqual(new Date(2026, 0, 15)); + expect(result.parsedDisabledDates![1]).toEqual(new Date(2026, 2, 20)); + expect(result.parsedDisabledRanges).toBeUndefined(); + }); + + it('parses date range arrays into [Date, Date] tuples', () => { + const result = parseDisabledDates([['2026-01-01', '2026-01-07']]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 7) + ); + expect(result.parsedDisabledDates).toBeUndefined(); + }); + + it('handles mixed individual dates and ranges', () => { + const result = parseDisabledDates([ + '2026-01-15', + ['2026-02-01', '2026-02-07'], + '2026-03-20', + ]); + expect(result.parsedDisabledDates).toHaveLength(2); + expect(result.parsedDisabledRanges).toHaveLength(1); + }); + + it('merges overlapping ranges', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-10'], + ['2026-01-05', '2026-01-15'], // overlaps with previous + ]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 15) + ); + }); + + it('merges adjacent ranges', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-10'], + ['2026-01-10', '2026-01-20'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(1); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 20) + ); + }); + + it('keeps non-overlapping ranges separate', () => { + const result = parseDisabledDates([ + ['2026-01-01', '2026-01-05'], + ['2026-01-10', '2026-01-15'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(2); + }); + + it('merges multiple overlapping ranges correctly', () => { + const result = parseDisabledDates([ + ['2026-03-01', '2026-03-10'], + ['2026-01-01', '2026-01-10'], + ['2026-01-05', '2026-01-20'], + ['2026-01-15', '2026-01-25'], + ]); + expect(result.parsedDisabledRanges).toHaveLength(2); + expect(result.parsedDisabledRanges![0][0]).toEqual( + new Date(2026, 0, 1) + ); + expect(result.parsedDisabledRanges![0][1]).toEqual( + new Date(2026, 0, 25) + ); + }); + + it('silently ignores invalid date strings', () => { + const result = parseDisabledDates(['invalid', '2026-01-15']); + expect(result.parsedDisabledDates).toHaveLength(1); + expect(result.parsedDisabledDates![0]).toEqual(new Date(2026, 0, 15)); + }); + + it('returns undefined arrays when no valid entries of that type exist', () => { + const result = parseDisabledDates(['2026-01-15']); + expect(result.parsedDisabledRanges).toBeUndefined(); + + const result2 = parseDisabledDates([['2026-01-01', '2026-01-07']]); + expect(result2.parsedDisabledDates).toBeUndefined(); + }); +}); + +describe('expandDisableFlags', () => { + const min = new Date(2026, 4, 1); // May 1, 2026 (Friday) + const max = new Date(2026, 4, 31); // May 31, 2026 (Sunday) + + it('returns empty arrays when no flags match', () => { + const result = expandDisableFlags([], min, max); + expect(result.dates).toHaveLength(0); + expect(result.ranges).toHaveLength(0); + }); + + it('expands weekends into ranges of two days', () => { + const result = expandDisableFlags('weekends', min, max); + // All ranges should be Saturday-Sunday pairs + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(6); // Saturday + expect(end.getDay()).toBe(0); // Sunday + }); + expect(result.dates).toHaveLength(0); + }); + + it('expands weekdays into ranges of five days', () => { + const result = expandDisableFlags('weekdays', min, max); + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(1); // Monday + expect(end.getDay()).toBe(5); // Friday + }); + // May 1 (Friday) is an isolated weekday at the boundary + expect(result.dates).toHaveLength(1); + }); + + it('expands a single day flag into individual dates', () => { + const result = expandDisableFlags('mondays', min, max); + expect(result.ranges).toHaveLength(0); + result.dates.forEach(date => { + expect(date.getDay()).toBe(1); // Monday + }); + // May 2026 has 4 Mondays (4, 11, 18, 25) + expect(result.dates).toHaveLength(4); + }); + + it('expands tuesdays correctly', () => { + const result = expandDisableFlags('tuesdays', min, max); + result.dates.forEach(date => expect(date.getDay()).toBe(2)); + // May 2026 has 5 Tuesdays (5, 12, 19, 26) - wait, also May 5 + expect(result.dates).toHaveLength(4); + }); + + it('handles array of individual day flags producing separate dates', () => { + const result = expandDisableFlags(['mondays', 'fridays'], min, max); + result.dates.forEach(date => { + expect([1, 5]).toContain(date.getDay()); + }); + expect(result.dates.length).toBeGreaterThan(0); + }); + + it('groups consecutive flags into ranges', () => { + const result = expandDisableFlags(['mondays', 'tuesdays'], min, max); + // Mon+Tue should be grouped into ranges, not individual dates + expect(result.ranges.length).toBeGreaterThan(0); + result.ranges.forEach(([start, end]) => { + expect(start.getDay()).toBe(1); // Monday + expect(end.getDay()).toBe(2); // Tuesday + }); + }); + + it('clamps ranges to min/max bounds', () => { + const tightMin = new Date(2026, 4, 3); // Sunday + const tightMax = new Date(2026, 4, 3); // Sunday only + const result = expandDisableFlags('weekends', tightMin, tightMax); + result.ranges.forEach(([start, end]) => { + expect(start.getTime()).toBeGreaterThanOrEqual(tightMin.getTime()); + expect(end.getTime()).toBeLessThanOrEqual(tightMax.getTime()); + }); + result.dates.forEach(date => { + expect(date.getTime()).toBeGreaterThanOrEqual(tightMin.getTime()); + expect(date.getTime()).toBeLessThanOrEqual(tightMax.getTime()); + }); + }); + + it('supports custom predicate function', () => { + const isThe15th = (d: Date) => d.getDate() === 15; + const result = expandDisableFlags(isThe15th, min, max); + expect(result.dates).toHaveLength(1); + expect(result.dates[0]).toEqual(new Date(2026, 4, 15)); + expect(result.ranges).toHaveLength(0); + }); + + it('all generated dates are within min/max', () => { + const result = expandDisableFlags('weekends', min, max); + const allDates = [ + ...result.dates, + ...result.ranges.flatMap(([s, e]) => [s, e]), + ]; + allDates.forEach(date => { + expect(date.getTime()).toBeGreaterThanOrEqual(min.getTime()); + expect(date.getTime()).toBeLessThanOrEqual(max.getTime()); + }); + }); +}); + +describe('snapToValidDate', () => { + it('returns the same date if it is not disabled', () => { + const date = new Date(2026, 4, 15); + expect(snapToValidDate(date)).toEqual(date); + }); + + it('snaps forward when date is inside a disabled range closer to end', () => { + const date = new Date(2026, 0, 8); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 11)); + }); + + it('snaps to nearest boundary of containing range (closer to start)', () => { + const date = new Date(2026, 0, 2); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2025, 11, 31)); + }); + + it('snaps to end of range when date is closer to end', () => { + const date = new Date(2026, 0, 18); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 21)); + }); + + it('snaps away from individually disabled dates', () => { + const date = new Date(2026, 0, 15); + const disabled = [new Date(2026, 0, 15)]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + disabled + ); + expect(result).not.toEqual(date); + }); + + it('snaps away from disabled flags', () => { + const saturday = new Date(2026, 4, 9); + const result = snapToValidDate( + saturday, + undefined, + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + expect([0, 6]).not.toContain(result.getDay()); + }); + + it('respects min/max bounds when snapping', () => { + const date = new Date(2026, 0, 5); + const minDate = new Date(2026, 0, 1); + const maxDate = new Date(2026, 0, 10); + const disabled = [ + new Date(2026, 0, 5), + new Date(2026, 0, 6), + new Date(2026, 0, 7), + ]; + const result = snapToValidDate( + date, + undefined, + minDate, + maxDate, + disabled + ); + expect(result.getTime()).toBeGreaterThanOrEqual(minDate.getTime()); + expect(result.getTime()).toBeLessThanOrEqual(maxDate.getTime()); + }); + + it('handles adjacent disabled ranges correctly', () => { + const date = new Date(2026, 0, 12); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + [new Date(2026, 0, 15), new Date(2026, 0, 20)], + ]; + const result = snapToValidDate( + date, + undefined, + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(date); + }); + + it('returns original date if no valid date found within bounds', () => { + const date = new Date(2026, 0, 5); + const minDate = new Date(2026, 0, 1); + const maxDate = new Date(2026, 0, 10); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 1), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + undefined, + minDate, + maxDate, + undefined, + ranges + ); + expect(result).toEqual(date); + }); + + it('snaps to step grid when inside disabled range', () => { + const date = new Date(2026, 0, 8); // inside range + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 5), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + '0:0:3', + undefined, + undefined, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 11)); + }); + + it('walks backward through step grid when after boundary is out of bounds', () => { + const date = new Date(2026, 0, 8); + const maxDate = new Date(2026, 0, 10); + const ranges: [Date, Date][] = [ + [new Date(2026, 0, 5), new Date(2026, 0, 10)], + ]; + const result = snapToValidDate( + date, + '0:0:3', + undefined, + maxDate, + undefined, + ranges + ); + expect(result).toEqual(new Date(2026, 0, 2)); + }); +}); + +describe('snapToStep', () => { + const anchor = new Date(2026, 0, 1); // Jan 1, 2026 + + it('returns same date if already on a step boundary', () => { + expect(snapToStep(anchor, anchor, '0:1:0')).toEqual(anchor); + expect(snapToStep(new Date(2026, 1, 1), anchor, '0:1:0')).toEqual( + new Date(2026, 1, 1) + ); + }); + + it('snaps to nearest monthly step', () => { + // Jan 20 — closer to Feb 1 than Jan 1 + const date = new Date(2026, 0, 20); + expect(snapToStep(date, anchor, '0:1:0')).toEqual(new Date(2026, 1, 1)); + + // Jan 10 — closer to Jan 1 than Feb 1 + const date2 = new Date(2026, 0, 10); + expect(snapToStep(date2, anchor, '0:1:0')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('snaps to nearest weekly step', () => { + // Jan 1 + 3 days — closer to Jan 1 than Jan 8 + const date = new Date(2026, 0, 4); + expect(snapToStep(date, anchor, '0:0:7')).toEqual(new Date(2026, 0, 1)); + + // Jan 1 + 5 days — closer to Jan 8 than Jan 1 + const date2 = new Date(2026, 0, 6); + expect(snapToStep(date2, anchor, '0:0:7')).toEqual( + new Date(2026, 0, 8) + ); + }); + + it('snaps to nearest yearly step', () => { + const date = new Date(2026, 8, 1); // Sep 2026 — closer to Jan 2027 + expect(snapToStep(date, anchor, '1:0:0')).toEqual(new Date(2027, 0, 1)); + + const date2 = new Date(2026, 2, 1); // Mar 2026 — closer to Jan 2026 + expect(snapToStep(date2, anchor, '1:0:0')).toEqual( + new Date(2026, 0, 1) + ); + }); + + it('handles dates before anchor', () => { + const date = new Date(2025, 10, 1); // Nov 2025 — before anchor Jan 2026 + const result = snapToStep(date, anchor, '0:1:0'); + // Should snap to nearest month boundary before anchor + expect(result.getDate()).toBe(1); + expect(result.getTime()).toBeLessThan(anchor.getTime()); + }); + + it('returns date unchanged for empty step string', () => { + const date = new Date(2026, 0, 15); + expect(snapToStep(date, anchor, '')).toEqual(date); + }); + + it('snaps correctly with daily step', () => { + const date = new Date(2026, 0, 5); + // With step of 1 day, every date is on the grid + expect(snapToStep(date, anchor, '0:0:1')).toEqual(date); + }); +}); + +describe('enforceNoDisabledInBetween', () => { + it('returns newDates unchanged when neither side changed', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-31'], + ['2026-05-01', '2026-05-31'] + ); + expect(result).toEqual(['2026-05-01', '2026-05-31']); + }); + + it('collapses to point when new left is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-10', '2026-05-20'], + ['2026-05-15', '2026-05-20'], + undefined, + undefined, + [new Date(2026, 4, 10)] + ); + expect(result).toEqual(['2026-05-10', '2026-05-10']); + }); + + it('collapses to point when new right is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-01', '2026-05-15'], + undefined, + undefined, + [new Date(2026, 4, 20)] + ); + expect(result).toEqual(['2026-05-20', '2026-05-20']); + }); + + it('clamps right when expanding left crosses a disabled date', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-10', '2026-05-20'], + undefined, + undefined, + [new Date(2026, 4, 5)] + ); + // Right should be clamped to just before May 5 + const [left, right] = result; + expect(left).toBe('2026-05-01'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 5).getTime() + ); + }); + + it('clamps left when expanding right crosses a disabled date', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-01', '2026-05-10'], + undefined, + undefined, + [new Date(2026, 4, 15)] + ); + const [left, right] = result; + expect(right).toBe('2026-05-20'); + expect(new Date(left).getTime()).toBeGreaterThan( + new Date(2026, 4, 15).getTime() + ); + }); + + it('clamps right when expanding left crosses a disabled range', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-20'], + ['2026-05-10', '2026-05-20'], + undefined, + undefined, + undefined, + [[new Date(2026, 4, 5), new Date(2026, 4, 7)]] + ); + const [left, right] = result; + expect(left).toBe('2026-05-01'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 5).getTime() + ); + }); + + it('clamps right when expanding left crosses a weekend flag', () => { + // Expanding left from May 12 to May 4 (Monday), with weekends disabled + // May 9-10 are Sat-Sun, so right should clamp before May 9 + const result = enforceNoDisabledInBetween( + ['2026-05-04', '2026-05-20'], + ['2026-05-12', '2026-05-20'], + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + const [left, right] = result; + expect(left).toBe('2026-05-04'); + expect(new Date(right).getTime()).toBeLessThan( + new Date(2026, 4, 9).getTime() + ); + }); + + it('clamps left when expanding right crosses a weekend flag', () => { + // Expanding right from May 12 to May 20, with weekends disabled + // May 16-17 are Sat-Sun, so left should clamp after May 17 + const result = enforceNoDisabledInBetween( + ['2026-05-04', '2026-05-20'], + ['2026-05-04', '2026-05-12'], + undefined, + undefined, + undefined, + undefined, + 'weekends' + ); + const [left, right] = result; + expect(right).toBe('2026-05-20'); + expect(new Date(left).getTime()).toBeGreaterThan( + new Date(2026, 4, 17).getTime() + ); + }); + + it('returns newDates unchanged when no disabled dates in between', () => { + const result = enforceNoDisabledInBetween( + ['2026-05-01', '2026-05-10'], + ['2026-05-05', '2026-05-10'], + undefined, + undefined, + [new Date(2026, 4, 20)] // disabled date outside range + ); + expect(result).toEqual(['2026-05-01', '2026-05-10']); + }); + + it('handles invalid date strings gracefully', () => { + const result = enforceNoDisabledInBetween( + ['invalid', '2026-05-20'], + ['2026-05-01', '2026-05-20'] + ); + expect(result).toEqual(['invalid', '2026-05-20']); + }); + + it('respects min/max when checking if new boundary is disabled', () => { + const result = enforceNoDisabledInBetween( + ['2026-04-25', '2026-05-20'], + ['2026-05-01', '2026-05-20'], + new Date(2026, 4, 1), // min = May 1 + new Date(2026, 4, 31) + ); + // Apr 25 is before min, so it's disabled — should collapse + expect(result).toEqual(['2026-04-25', '2026-04-25']); + }); +}); diff --git a/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts new file mode 100644 index 0000000000..333a7e5500 --- /dev/null +++ b/components/dash-core-components/tests/unit/computeDateSliderMarkers.test.ts @@ -0,0 +1,251 @@ +/* eslint-disable no-magic-numbers */ +import {SliderMarks} from '../../src/types'; +import {autoGenerateDateMarks} from '../../src/utils/computeDateSliderMarkers'; + +const getMarkPositions = (marks: SliderMarks): number[] => { + if (!marks) { + return []; + } + return Object.keys(marks) + .map(Number) + .sort((a, b) => a - b); +}; + +const toUtcTs = (dateStr: string) => { + const [year, month, day] = dateStr.split('-').map(Number); + return Date.UTC(year, month - 1, day); +}; + +describe('autoGenerateDateMarks', () => { + describe('Basic behavior', () => { + test('always includes min and max', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions[0]).toBe(toUtcTs('2026-05-01')); + expect(positions[positions.length - 1]).toBe(toUtcTs('2026-05-31')); + }); + + test('returns at least min and max for very narrow slider', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 50 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + + test('returns correct mark labels using format string', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-03'); + const marks = autoGenerateDateMarks( + min, + max, + '0:0:1', + 'YYYY-MM-DD', + 1000 + ); + expect(marks[toUtcTs('2026-05-01')]).toBe('2026-05-01'); + expect(marks[toUtcTs('2026-05-02')]).toBe('2026-05-02'); + expect(marks[toUtcTs('2026-05-03')]).toBe('2026-05-03'); + }); + + test('handles undefined sliderWidth with a default', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + undefined + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + expect(positions[0]).toBe(toUtcTs('2026-05-01')); + expect(positions[positions.length - 1]).toBe(toUtcTs('2026-05-31')); + }); + + test('handles null sliderWidth with a default', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + null + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Step behavior', () => { + test('generates marks on valid step positions (weekly step)', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + '0:0:7', + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length - 1; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff % 7).toBe(0); + } + }); + + test('generates marks on valid step positions (every 3 days)', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const marks = autoGenerateDateMarks( + min, + max, + '0:0:3', + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length - 1; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff % 3).toBe(0); + } + }); + + test('defaults to daily step when no step provided', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-05'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 1600 + ); + const positions = getMarkPositions(marks); + for (let i = 1; i < positions.length; i++) { + const diff = + (positions[i] - positions[i - 1]) / (1000 * 60 * 60 * 24); + expect(diff).toBe(1); + } + }); + }); + + describe('Width scaling behavior', () => { + test('wider slider shows more marks than narrow slider', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const narrow = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 100 + ); + const wide = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 1000 + ); + expect(getMarkPositions(wide).length).toBeGreaterThanOrEqual( + getMarkPositions(narrow).length + ); + }); + + test('marks increase proportionally with width', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const w100 = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 100) + ); + const w330 = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 330) + ); + const w660 = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 660) + ); + expect(w100.length).toBeLessThanOrEqual(w330.length); + expect(w330.length).toBeLessThanOrEqual(w660.length); + }); + }); + + describe('Label format impact on density', () => { + test('longer format strings result in fewer marks than shorter ones', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-31'); + const shortFormat = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'DD', 330) + ); + const longFormat = getMarkPositions( + autoGenerateDateMarks(min, max, undefined, 'YYYY-MM-DD', 330) + ); + expect(longFormat.length).toBeLessThanOrEqual(shortFormat.length); + }); + }); + + describe('Edge cases', () => { + test('min equals max returns single mark', () => { + const date = new Date('2026-05-15'); + const marks = autoGenerateDateMarks( + date, + date, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBe(1); + expect(positions[0]).toBe(toUtcTs('2026-05-15')); + }); + + test('range of two days returns both marks', () => { + const min = new Date('2026-05-01'); + const max = new Date('2026-05-02'); + const marks = autoGenerateDateMarks( + min, + max, + undefined, + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions).toContain(toUtcTs('2026-05-01')); + expect(positions).toContain(toUtcTs('2026-05-02')); + }); + + test('large date range with yearly step does not create too many marks', () => { + const min = new Date('2026-05-01'); + const max = new Date('2056-05-01'); + const marks = autoGenerateDateMarks( + min, + max, + '1:0:0', + 'YYYY-MM-DD', + 330 + ); + const positions = getMarkPositions(marks); + expect(positions.length).toBeLessThanOrEqual(15); + expect(positions.length).toBeGreaterThanOrEqual(2); + }); + }); +});