diff --git a/src/package/rangeflow/components/DateSlider/DateLabelsTrack.tsx b/src/package/rangeflow/components/DateSlider/DateLabelsTrack.tsx index 0a2b18a..c5dda46 100644 --- a/src/package/rangeflow/components/DateSlider/DateLabelsTrack.tsx +++ b/src/package/rangeflow/components/DateSlider/DateLabelsTrack.tsx @@ -1,11 +1,11 @@ import clsx from 'clsx' -import dayjs from 'dayjs' import { createElement, memo, useMemo } from 'react' import { OdometerText } from '../../animations/OdometerText' import { useDaysInRange } from '../../hooks/use-days-in-range' import { useRangeFlowSlots } from '../../hooks/use-rangeflow-slots' import { useRangeFlowStore } from '../../hooks/use-rangeflow-store' +import { normalizeDateRange } from '../../utils/normalize-date-range' function getLabelFormat(daysInRange: number): string { if (daysInRange > 120) { @@ -42,12 +42,13 @@ export const DateLabelsTrack = memo(() => { const labels = useMemo(() => { const format = getLabelFormat(daysInRange) - const count = getLabelCount(daysInRange) - const start = dayjs(range.from) - const totalMs = dayjs(range.to).diff(start) + const count = Math.max(getLabelCount(daysInRange), 1) + const { start, end } = normalizeDateRange(range) + const totalMs = end.diff(start) + const steps = Math.max(count - 1, 1) return Array.from({ length: count }, (_, i) => { - const ratio = i / (count - 1) + const ratio = i / steps return start.add(totalMs * ratio, 'ms').format(format) }) diff --git a/src/package/rangeflow/components/DateSlider/SliderValue.tsx b/src/package/rangeflow/components/DateSlider/SliderValue.tsx index 9a268f4..d4fceca 100644 --- a/src/package/rangeflow/components/DateSlider/SliderValue.tsx +++ b/src/package/rangeflow/components/DateSlider/SliderValue.tsx @@ -11,7 +11,13 @@ export const SliderValue = memo(() => { const selected = useRangeFlowStore(state => state.selected_date) const label = useMemo(() => { - const days = dayjs(selected.to).diff(selected.from, 'day') + 1 + const from = dayjs(selected.from) + const to = dayjs(selected.to) + + const safeFrom = from.isValid() ? from : to + const safeTo = to.isValid() ? to : from + const rawDays = safeTo.isValid() && safeFrom.isValid() ? safeTo.diff(safeFrom, 'day') + 1 : 1 + const days = Number.isFinite(rawDays) ? Math.max(rawDays, 1) : 1 if (size < 10) { return `${days}D` diff --git a/src/package/rangeflow/components/PickerBar/CalendarPopover.tsx b/src/package/rangeflow/components/PickerBar/CalendarPopover.tsx index 755e623..2e2ef96 100644 --- a/src/package/rangeflow/components/PickerBar/CalendarPopover.tsx +++ b/src/package/rangeflow/components/PickerBar/CalendarPopover.tsx @@ -25,15 +25,28 @@ export function CalendarPopover({ children }: Props) { } = useRangeFlowRefs() const extendRange = () => { - let rangeFrom = dayjs(range.from) - let rangeTo = dayjs(range.to) + const rangeFromDate = dayjs(range.from) + const rangeToDate = dayjs(range.to) + const selectedFrom = dayjs(date.from) + const selectedTo = dayjs(date.to) - if (dayjs(date.from).isBefore(rangeFrom)) { - rangeFrom = dayjs(date.from).subtract(10, 'day') + let rangeFrom = rangeFromDate.isValid() ? rangeFromDate : dayjs() + let rangeTo = rangeToDate.isValid() ? rangeToDate : rangeFrom + + if (rangeTo.isBefore(rangeFrom)) { + rangeTo = rangeFrom + } + + if (selectedFrom.isValid() && selectedFrom.isBefore(rangeFrom)) { + rangeFrom = selectedFrom.subtract(10, 'day') } - if (dayjs(date.to).isAfter(rangeTo)) { - rangeTo = dayjs(date.to).add(10, 'day') + if (selectedTo.isValid() && selectedTo.isAfter(rangeTo)) { + rangeTo = selectedTo.add(10, 'day') + } + + if (rangeTo.isBefore(rangeFrom)) { + rangeTo = rangeFrom.add(1, 'day') } return { @@ -67,7 +80,7 @@ export function CalendarPopover({ children }: Props) { {children} diff --git a/src/package/rangeflow/components/PickerBar/SelectedDate.tsx b/src/package/rangeflow/components/PickerBar/SelectedDate.tsx index 311adc9..ca1090d 100644 --- a/src/package/rangeflow/components/PickerBar/SelectedDate.tsx +++ b/src/package/rangeflow/components/PickerBar/SelectedDate.tsx @@ -14,19 +14,18 @@ export function SelectedDate() { const start = dayjs(date.from) const end = dayjs(date.to) - const formatter = start.isSame(end, 'year') ? 'DD MMM' : 'DD MMM YYYY' const labels = { - from: start.format(formatter), - to: end.format(formatter) + from: start.isValid() ? start.format(formatter) : 'Invalid Date', + to: end.isValid() ? end.format(formatter) : 'Invalid Date' } - if (today.isSame(start, 'day')) { + if (start.isValid() && today.isSame(start, 'day')) { labels.from = 'Today' } - if (today.isSame(end, 'day')) { + if (end.isValid() && today.isSame(end, 'day')) { labels.to = 'Today' } diff --git a/src/package/rangeflow/hooks/use-days-in-range.ts b/src/package/rangeflow/hooks/use-days-in-range.ts index 79b1e91..4b892b0 100644 --- a/src/package/rangeflow/hooks/use-days-in-range.ts +++ b/src/package/rangeflow/hooks/use-days-in-range.ts @@ -1,8 +1,12 @@ -import dayjs from 'dayjs' import { useMemo } from 'react' import type { DateRange } from '../types' +import { normalizeDateRange } from '../utils/normalize-date-range' export function useDaysInRange(range: DateRange) { - return useMemo(() => dayjs(range.to).diff(dayjs(range.from), 'day'), [range.from, range.to]) + return useMemo(() => { + const { start, end } = normalizeDateRange(range) + const rawDiff = end.diff(start, 'day') + return Number.isFinite(rawDiff) ? Math.max(rawDiff + 1, 1) : 1 + }, [range.from, range.to]) } diff --git a/src/package/rangeflow/utils/create-slider-values.ts b/src/package/rangeflow/utils/create-slider-values.ts index 15766d6..bc6a1b1 100644 --- a/src/package/rangeflow/utils/create-slider-values.ts +++ b/src/package/rangeflow/utils/create-slider-values.ts @@ -4,6 +4,7 @@ import { SLIDER_THUMB_MIN_SIZE } from '../constants/slider' import type { DateRange } from '../types' import { clamp } from './clamp' import { interpolate } from './interpolate' +import { normalizeDateRange } from './normalize-date-range' const toVisual = interpolate([1, 100], [SLIDER_THUMB_MIN_SIZE, 100]) @@ -14,14 +15,31 @@ export function createSliderValues( to: Date } ) { - const rangeStart = dayjs(range.from).startOf('day') - const daysInRange = dayjs(range.to).startOf('day').diff(rangeStart, 'day') + const { start: rangeStart, end: rangeEnd } = normalizeDateRange(range) - const fromDay = dayjs(selected.from).startOf('day') - const toDay = dayjs(selected.to).startOf('day') + const rawRangeDays = rangeEnd.diff(rangeStart, 'day') + const daysInRange = Number.isFinite(rawRangeDays) + ? Math.max(rawRangeDays + 1, 1) + : 1 - const pastDays = Math.max(fromDay.diff(rangeStart, 'day'), 0) - const selectedDays = toDay.diff(fromDay, 'day') + 1 + const fromDay = dayjs(selected.from) + const toDay = dayjs(selected.to) + const safeFrom = fromDay.isValid() ? fromDay.startOf('day') : rangeStart + let safeTo = toDay.isValid() ? toDay.startOf('day') : safeFrom + + if (safeTo.isBefore(safeFrom)) { + safeTo = safeFrom + } + + const rawPastDays = safeFrom.diff(rangeStart, 'day') + const pastDays = Number.isFinite(rawPastDays) + ? Math.max(Math.min(rawPastDays, daysInRange - 1), 0) + : 0 + + const rawSelectedDays = safeTo.diff(safeFrom, 'day') + 1 + const selectedDays = Number.isFinite(rawSelectedDays) + ? Math.max(Math.min(rawSelectedDays, daysInRange), 1) + : 1 const rawSize = (selectedDays * 100) / daysInRange const rawLeft = (pastDays * 100) / daysInRange diff --git a/src/package/rangeflow/utils/derive-selection-from-layout.ts b/src/package/rangeflow/utils/derive-selection-from-layout.ts index f14ea8b..9c7dbf1 100644 --- a/src/package/rangeflow/utils/derive-selection-from-layout.ts +++ b/src/package/rangeflow/utils/derive-selection-from-layout.ts @@ -10,6 +10,7 @@ import { import type { DateRange } from '../types' import { clamp } from './clamp' import { interpolate } from './interpolate' +import { normalizeDateRange } from './normalize-date-range' // Inverse of the `toVisual` mapping in createSliderValues: [MIN, 100] → [1, 100]. const fromVisual = interpolate([SLIDER_THUMB_MIN_SIZE, 100], [1, 100]) @@ -21,20 +22,29 @@ export function deriveSelectionFromLayout(layout: Layout, range: DateRange) { const left = layout[SLIDER_LEFT_SPACER] const right = layout[SLIDER_RIGHT_SPACER] - const start = dayjs(range.from).startOf('day') - const daysInRange = dayjs(range.to).startOf('day').diff(start, 'day') + const { start, end } = normalizeDateRange(range) + const rawRangeDays = end.diff(start, 'day') + const daysInRange = Number.isFinite(rawRangeDays) + ? Math.max(rawRangeDays + 1, 1) + : 1 + + const safeSize = Number.isFinite(size) ? size : SLIDER_THUMB_MIN_SIZE + const safeLeft = Number.isFinite(left) ? left : 0 + const safeRight = Number.isFinite(right) + ? right + : Math.max(100 - safeLeft - safeSize, 0) + const actualSize = fromVisual(safeSize) + const actualLeft = safeLeft + (safeSize - actualSize) // Undo the inflation createSliderValues applied to size (absorbed by the // left spacer). The right spacer is already unscaled, so no inversion there. - const actualSize = fromVisual(size) - const actualLeft = left + (size - actualSize) // Derive totalDays from the thumb width (the authoritative value), not from // the leftover between startDay and trailingDays — otherwise independent // rounding of the two spacers can flip the selection length by ±1 day // while the user is only translating the thumb across the track. const totalDays = - Math.round(size) <= SLIDER_THUMB_MIN_SIZE + Math.round(safeSize) <= SLIDER_THUMB_MIN_SIZE ? 1 : Math.max(Math.round((actualSize * daysInRange) / 100), 1) diff --git a/src/package/rangeflow/utils/normalize-date-range.ts b/src/package/rangeflow/utils/normalize-date-range.ts new file mode 100644 index 0000000..f837f89 --- /dev/null +++ b/src/package/rangeflow/utils/normalize-date-range.ts @@ -0,0 +1,17 @@ +import dayjs from 'dayjs' +import type { DateRange } from '../types' + +export function normalizeDateRange(range: DateRange) { + const now = dayjs().startOf('day') + const startDate = dayjs(range.from) + const endDate = dayjs(range.to) + + const start = startDate.isValid() ? startDate.startOf('day') : now + let end = endDate.isValid() ? endDate.startOf('day') : start + + if (end.isBefore(start)) { + end = start + } + + return { start, end } +}