Skip to content

Commit a6b1b62

Browse files
committed
feat(time-slice): smarter default input formatter; add support for future time ranges from natural language
1 parent 369bf2b commit a6b1b62

File tree

4 files changed

+264
-21
lines changed

4 files changed

+264
-21
lines changed

src/timeslice/time-slice.stories.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,93 @@ export const Absolute = () => {
106106
)
107107
}
108108

109+
export const WithFutureShortcuts = () => {
110+
const [dateRange, setDateRange] = React.useState<TimeSliceProps['dateRange']>(
111+
{
112+
startDate: undefined,
113+
endDate: undefined
114+
}
115+
)
116+
117+
return (
118+
<>
119+
<TimeSlice.Root onDateRangeChange={setDateRange} dateRange={dateRange}>
120+
<TimeSlice.Input style={{ border: '1px solid black', width: '100%' }} />
121+
<TimeSlice.Portal
122+
style={{ border: '1px solid black', backgroundColor: 'white' }}
123+
>
124+
<TimeSlice.Shortcut duration={{ minutes: 15 }} asChild>
125+
<div className="focus:bg-gray-100">15 minutes</div>
126+
</TimeSlice.Shortcut>
127+
<TimeSlice.Shortcut
128+
className="focus:bg-gray-100"
129+
duration={{ hours: -1 }}
130+
>
131+
<div>Next hour</div>
132+
</TimeSlice.Shortcut>
133+
</TimeSlice.Portal>
134+
</TimeSlice.Root>
135+
136+
<pre>{JSON.stringify(dateRange, null, 2)}</pre>
137+
</>
138+
)
139+
}
140+
141+
export const Controlled = () => {
142+
const [dateRange, setDateRange] = React.useState<TimeSliceProps['dateRange']>(
143+
{
144+
startDate: undefined,
145+
endDate: undefined
146+
}
147+
)
148+
149+
return (
150+
<>
151+
<h1>Prevents future dates via controlled state</h1>
152+
<TimeSlice.Root
153+
onDateRangeChange={({ startDate, endDate }) => {
154+
// prevent future dates
155+
if (startDate && endDate && endDate > new Date()) {
156+
return
157+
}
158+
159+
setDateRange({ startDate, endDate })
160+
}}
161+
dateRange={dateRange}
162+
>
163+
<TimeSlice.Input style={{ border: '1px solid black', width: '100%' }} />
164+
<TimeSlice.Portal
165+
style={{ border: '1px solid black', backgroundColor: 'white' }}
166+
>
167+
<TimeSlice.Shortcut duration={{ minutes: 15 }} asChild>
168+
<div className="focus:bg-gray-100">15 minutes</div>
169+
</TimeSlice.Shortcut>
170+
<TimeSlice.Shortcut
171+
className="focus:bg-gray-100"
172+
duration={{ hours: 1 }}
173+
>
174+
<div>1 hour</div>
175+
</TimeSlice.Shortcut>
176+
<TimeSlice.Shortcut
177+
className="focus:bg-gray-100"
178+
duration={{ days: 1 }}
179+
>
180+
<div>1 day</div>
181+
</TimeSlice.Shortcut>
182+
<TimeSlice.Shortcut
183+
className="focus:bg-gray-100"
184+
duration={{ years: 1 }}
185+
>
186+
<div>1 year</div>
187+
</TimeSlice.Shortcut>
188+
</TimeSlice.Portal>
189+
</TimeSlice.Root>
190+
191+
<pre>{JSON.stringify(dateRange, null, 2)}</pre>
192+
</>
193+
)
194+
}
195+
109196
export const DataDog = () => {
110197
const [dateRange, setDateRange] = React.useState<TimeSliceProps['dateRange']>(
111198
{

src/timeslice/time-slice.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ describe('TimeSlice Component Family', () => {
653653
expect(mockSetOpen).toHaveBeenCalledWith(false)
654654
expect(inputElement).not.toHaveFocus()
655655

656-
expect(inputElement).toHaveValue('Past 7 days')
656+
expect(inputElement).toHaveValue('Past 1 week')
657657
})
658658

659659
it('should render as child, forward props, and maintain click functionality (relative format)', () => {

src/timeslice/time-slice.tsx

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { composeRefs } from '@radix-ui/react-compose-refs'
44
import type { Scope } from '@radix-ui/react-context'
55
import { DismissableLayer } from '@radix-ui/react-dismissable-layer'
66
import { Slot } from '@radix-ui/react-slot'
7-
import { sub, formatDistanceStrict } from 'date-fns'
7+
import { sub, add, Duration } from 'date-fns'
88
import React, { useCallback, useMemo, useId, useState, useEffect } from 'react'
99
import { useTimeSliceState, type DateRange } from './hooks/use-time-slice-state'
1010
import {
1111
useSegmentNavigation,
1212
buildSegments
1313
} from './hooks/use-segment-navigation/use-segment-navigation'
1414
import { parseDateInput } from './utils/date-parser'
15+
import { formatTimeRange } from './utils/time-range'
1516

1617
export type TimeZone = keyof typeof Timezone
1718

@@ -83,14 +84,28 @@ const TimeSlice: React.FC<TimeSliceProps> = ({
8384

8485
const calculateIsRelative = useCallback((range: DateRange): boolean => {
8586
let shouldBeRelative = false
86-
if (
87-
range.endDate &&
88-
new Date().getTime() - range.endDate.getTime() < 1000 * 60
89-
) {
90-
if (range.endDate.getTime() - new Date().getTime() <= 1000 * 60) {
87+
const nowMs = new Date().getTime()
88+
const THRESHOLD_MS = 5000
89+
90+
if (range.endDate) {
91+
const endDateMs = range.endDate.getTime()
92+
if (
93+
endDateMs > nowMs - THRESHOLD_MS &&
94+
endDateMs <= nowMs + THRESHOLD_MS
95+
) {
9196
shouldBeRelative = true
9297
}
9398
}
99+
100+
if (!shouldBeRelative && range.startDate && range.endDate) {
101+
const startDateMs = range.startDate.getTime()
102+
const endDateMs = range.endDate.getTime()
103+
104+
if (Math.abs(startDateMs - nowMs) <= THRESHOLD_MS && endDateMs > nowMs) {
105+
shouldBeRelative = true
106+
}
107+
}
108+
94109
return shouldBeRelative
95110
}, [])
96111

@@ -117,17 +132,13 @@ const TimeSlice: React.FC<TimeSliceProps> = ({
117132
startDate?: Date
118133
endDate?: Date
119134
isRelative: boolean
120-
}): string => {
121-
if (!startDate || !endDate) return ''
122-
if (isRelative && endDate > startDate) {
123-
const human = formatDistanceStrict(startDate, endDate, {
124-
roundingMethod: 'round'
125-
})
126-
return `Past ${human}`
127-
} else {
128-
return buildSegments(startDate, endDate, timeZone).text
129-
}
130-
},
135+
}) =>
136+
formatTimeRange({
137+
start: startDate,
138+
end: endDate,
139+
relative: isRelative,
140+
timeZone
141+
}),
131142
[timeZone]
132143
)
133144

@@ -502,11 +513,33 @@ const TimeSliceShortcut = React.forwardRef<
502513
(e: React.MouseEvent<HTMLDivElement>) => {
503514
e.preventDefault()
504515
const now = new Date()
505-
const startDate = sub(now, duration)
506-
const endDate = now
516+
let finalStartDate: Date
517+
let finalEndDate: Date
518+
519+
const isFutureIntent = Object.values(duration).some(
520+
(val) => val !== undefined && val < 0
521+
)
522+
523+
const normalizedDuration: Duration = {}
524+
;(Object.keys(duration) as Array<keyof typeof duration>).forEach(
525+
(key) => {
526+
const value = duration[key]
527+
if (value !== undefined) {
528+
normalizedDuration[key] = Math.abs(value)
529+
}
530+
}
531+
)
532+
533+
if (isFutureIntent) {
534+
finalStartDate = now
535+
finalEndDate = add(now, normalizedDuration)
536+
} else {
537+
finalStartDate = sub(now, normalizedDuration)
538+
finalEndDate = now
539+
}
507540

508541
setInternalIsRelative(true)
509-
setDateRange({ startDate, endDate })
542+
setDateRange({ startDate: finalStartDate, endDate: finalEndDate })
510543
setOpen(false)
511544
if (inputRef.current) {
512545
inputRef.current.blur()

src/timeslice/utils/time-range.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {
2+
formatDistanceStrict,
3+
differenceInYears,
4+
addYears,
5+
differenceInMonths,
6+
addMonths,
7+
differenceInWeeks,
8+
addWeeks,
9+
differenceInDays,
10+
addDays,
11+
differenceInHours,
12+
addHours,
13+
differenceInMinutes,
14+
addMinutes,
15+
isEqual
16+
} from 'date-fns'
17+
import { buildSegments } from '../hooks/use-segment-navigation'
18+
19+
export const formatTimeRange = ({
20+
start,
21+
end,
22+
relative,
23+
timeZone
24+
}: {
25+
start?: Date
26+
end?: Date
27+
relative: boolean
28+
timeZone: string
29+
}): string => {
30+
if (!start || !end) return ''
31+
32+
if (relative) {
33+
if (isEqual(start!, end!)) {
34+
return buildSegments(start!, end!, timeZone).text
35+
}
36+
37+
const sMs = start!.getTime()
38+
const eMs = end!.getTime()
39+
const nowMs = new Date().getTime()
40+
const threshold = 5000
41+
let prefix: string
42+
43+
if (Math.abs(sMs - nowMs) <= threshold) {
44+
prefix = eMs > sMs ? 'Next' : 'Past'
45+
} else if (Math.abs(eMs - nowMs) <= threshold) {
46+
prefix = sMs < eMs ? 'Past' : 'Next'
47+
} else {
48+
prefix = eMs > nowMs ? 'Next' : 'Past'
49+
}
50+
51+
let earlierDate: Date
52+
let laterDate: Date
53+
54+
if (eMs > sMs) {
55+
earlierDate = start
56+
laterDate = end
57+
} else {
58+
earlierDate = end
59+
laterDate = start
60+
}
61+
62+
const exactYears = differenceInYears(laterDate, earlierDate)
63+
if (
64+
exactYears > 0 &&
65+
isEqual(addYears(earlierDate, exactYears), laterDate)
66+
) {
67+
return `${prefix} ${formatDistanceStrict(earlierDate, laterDate, { unit: 'year', roundingMethod: 'trunc' })}`
68+
}
69+
70+
const exactMonths = differenceInMonths(laterDate, earlierDate)
71+
if (
72+
exactMonths > 0 &&
73+
isEqual(addMonths(earlierDate, exactMonths), laterDate)
74+
) {
75+
return `${prefix} ${formatDistanceStrict(earlierDate, laterDate, { unit: 'month', roundingMethod: 'trunc' })}`
76+
}
77+
78+
const exactWeeks = differenceInWeeks(laterDate, earlierDate)
79+
if (
80+
exactWeeks > 0 &&
81+
isEqual(addWeeks(earlierDate, exactWeeks), laterDate)
82+
) {
83+
if (exactWeeks === 1) {
84+
return `${prefix} 1 week`
85+
} else {
86+
return `${prefix} ${exactWeeks} weeks`
87+
}
88+
}
89+
90+
const exactDays = differenceInDays(laterDate, earlierDate)
91+
if (
92+
exactDays > 0 &&
93+
exactDays < 30 &&
94+
isEqual(addDays(earlierDate, exactDays), laterDate)
95+
) {
96+
return `${prefix} ${formatDistanceStrict(earlierDate, laterDate, { unit: 'day', roundingMethod: 'trunc' })}`
97+
}
98+
99+
const exactHours = differenceInHours(laterDate, earlierDate)
100+
if (
101+
exactHours > 0 &&
102+
exactHours < 24 &&
103+
isEqual(addHours(earlierDate, exactHours), laterDate)
104+
) {
105+
return `${prefix} ${formatDistanceStrict(earlierDate, laterDate, { unit: 'hour', roundingMethod: 'trunc' })}`
106+
}
107+
108+
const exactMinutes = differenceInMinutes(laterDate, earlierDate)
109+
if (
110+
exactMinutes > 0 &&
111+
exactMinutes < 60 &&
112+
isEqual(addMinutes(earlierDate, exactMinutes), laterDate)
113+
) {
114+
return `${prefix} ${formatDistanceStrict(earlierDate, laterDate, { unit: 'minute', roundingMethod: 'trunc' })}`
115+
}
116+
117+
// Fallback for relative cases if no specific "exact" unit match was found
118+
return buildSegments(start!, end!, timeZone).text
119+
} else {
120+
// Not relative, or start/end were initially undefined (already handled)
121+
return buildSegments(start!, end!, timeZone).text
122+
}
123+
}

0 commit comments

Comments
 (0)