11import { useSearchParams } from '@remix-run/react' ;
2- import { subMonths } from 'date-fns' ;
2+ import { subDays , subMonths } from 'date-fns' ;
33import { useCallback , useMemo } from 'react' ;
44
55type StaticDateRange = { type : 'static' ; startDate : string ; endDate : string } ;
66type DynamicDateRange = { type : 'dynamic' ; fromNow : string } ;
77type DateRangeFilterParam = StaticDateRange | DynamicDateRange | null | undefined ;
8+ type IsoRange = { start : string ; end : string } ;
89
910function toIso ( date : Date ) : string {
1011 return date . toISOString ( ) ;
@@ -18,224 +19,107 @@ function getDefaultRange(): { start: string; end: string } {
1819
1920export function useDateRangeSearchParams ( ) {
2021 const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
21- const encodeBase64Url = ( value : string ) =>
22- btoa ( value ) . replace ( / \+ / g, '-' ) . replace ( / \/ / g, '_' ) . replace ( / = + $ / g, '' ) ;
23- const decodeBase64Url = ( value : string ) => {
24- const withPadding = value . replace ( / - / g, '+' ) . replace ( / _ / g, '/' ) ;
25- const pad = withPadding . length % 4 ? 4 - ( withPadding . length % 4 ) : 0 ;
26- const padded = withPadding + '=' . repeat ( pad ) ;
27- return atob ( padded ) ;
28- } ;
29-
30- const { range, compareRange } = useMemo ( ( ) => {
31- const q = searchParams . get ( 'q' ) ;
32- if ( q ) {
33- try {
34- const obj = JSON . parse ( decodeBase64Url ( q ) ) as {
35- range ?: { start ?: string ; end ?: string } | null ;
36- compareRange ?: { start ?: string ; end ?: string } | null ;
37- } ;
38- // New shape
39- if ( 'range' in obj && obj . range ?. start && obj . range ?. end ) {
40- return {
41- range : { start : obj . range . start , end : obj . range . end } ,
42- compareRange :
43- obj . compareRange ?. start && obj . compareRange ?. end
44- ? { start : obj . compareRange . start , end : obj . compareRange . end }
45- : null ,
46- } as const ;
22+ const parseQ = useCallback (
23+ ( qValue : string | null ) : { range : IsoRange ; compareRange : IsoRange | null } => {
24+ if ( qValue ) {
25+ try {
26+ const obj = JSON . parse ( atob ( qValue ) ) as {
27+ range ?: { start ?: string ; end ?: string } | null ;
28+ compareRange ?: { start ?: string ; end ?: string } | null ;
29+ } ;
30+ if ( obj ?. range ?. start && obj ?. range ?. end ) {
31+ return {
32+ range : { start : obj . range . start , end : obj . range . end } ,
33+ compareRange :
34+ obj . compareRange ?. start && obj . compareRange ?. end
35+ ? { start : obj . compareRange . start , end : obj . compareRange . end }
36+ : null ,
37+ } ;
38+ }
39+ } catch {
40+ // ignore malformed q
4741 }
48- // otherwise fallthrough to defaults below
49- } catch {
50- // ignore malformed q
5142 }
52- }
53- const defaults = getDefaultRange ( ) ;
54- return { range : { start : defaults . start , end : defaults . end } , compareRange : null } as const ;
55- } , [ searchParams ] ) ;
43+ const defaults = getDefaultRange ( ) ;
44+ return { range : { start : defaults . start , end : defaults . end } , compareRange : null } ;
45+ } ,
46+ [ ] ,
47+ ) ;
48+
49+ const { range, compareRange } = useMemo (
50+ ( ) => parseQ ( searchParams . get ( 'q' ) ) ,
51+ [ parseQ , searchParams ] ,
52+ ) ;
53+
54+ const computeDynamicRange = useCallback ( ( fromNow : string ) : IsoRange => {
55+ const now = new Date ( ) ;
56+ const match = / - ? P - ? ( \d + ) ( [ M D ] ) / i. exec ( fromNow ) ;
57+ const amount = match && match [ 1 ] ? Number ( match [ 1 ] ) : 1 ;
58+ const unit = match && match [ 2 ] ? match [ 2 ] . toUpperCase ( ) : 'M' ;
59+ const startDate = unit === 'D' ? subDays ( now , amount ) : subMonths ( now , amount ) ;
60+ return { start : toIso ( startDate ) , end : toIso ( now ) } ;
61+ } , [ ] ) ;
62+
63+ const writeQ = useCallback (
64+ ( prevParams : URLSearchParams , next : { range ?: IsoRange ; compareRange ?: IsoRange | null } ) => {
65+ const params = new URLSearchParams ( prevParams ) ;
66+ const current = parseQ ( params . get ( 'q' ) ) ;
67+ const nextPayload = {
68+ range : next . range ?? current . range ,
69+ compareRange : next . compareRange === undefined ? current . compareRange : next . compareRange ,
70+ } ;
71+ params . set ( 'q' , btoa ( JSON . stringify ( nextPayload ) ) ) ;
72+ params . delete ( 'compareStart' ) ;
73+ params . delete ( 'compareEnd' ) ;
74+ params . delete ( 'start' ) ;
75+ params . delete ( 'end' ) ;
76+ return params ;
77+ } ,
78+ [ parseQ ] ,
79+ ) ;
5680
5781 const setDateRangeFilter = useCallback (
5882 ( dateRange : DateRangeFilterParam ) => {
5983 setSearchParams (
6084 ( prev ) => {
61- const params = new URLSearchParams ( prev ) ;
62- const writeQ = ( payload : {
63- range : { start : string ; end : string } ;
64- compareRange ?: { start : string ; end : string } | null ;
65- } ) => {
66- params . set ( 'q' , encodeBase64Url ( JSON . stringify ( payload ) ) ) ;
67- } ;
68-
6985 if ( ! dateRange ) {
70- const d = getDefaultRange ( ) ;
71- // Clear to defaults; preserve existing compareRange if any
72- const qv = params . get ( 'q' ) ;
73- let existingCompare : { start : string ; end : string } | null = null ;
74- if ( qv ) {
75- try {
76- const parsed = JSON . parse ( decodeBase64Url ( qv ) ) as {
77- compareRange ?: { start ?: string ; end ?: string } | null ;
78- } ;
79- if ( parsed . compareRange ?. start && parsed . compareRange ?. end ) {
80- existingCompare = {
81- start : parsed . compareRange . start ,
82- end : parsed . compareRange . end ,
83- } ;
84- }
85- } catch {
86- // ignore
87- }
88- }
89- writeQ ( { range : { start : d . start , end : d . end } , compareRange : existingCompare } ) ;
90- return params ;
86+ const defaults = getDefaultRange ( ) ;
87+ return writeQ ( prev , { range : defaults , compareRange : undefined } ) ;
9188 }
9289
9390 if ( dateRange . type === 'static' ) {
94- const current = { start : dateRange . startDate , end : dateRange . endDate } as const ;
95- // retain existing compareRange if any
96- const qv = params . get ( 'q' ) ;
97- if ( qv ) {
98- try {
99- const parsed = JSON . parse ( decodeBase64Url ( qv ) ) as {
100- compareRange ?: { start ?: string ; end ?: string } | null ;
101- } ;
102- const compare =
103- parsed . compareRange ?. start && parsed . compareRange ?. end
104- ? { start : parsed . compareRange . start , end : parsed . compareRange . end }
105- : null ;
106- writeQ ( { range : current , compareRange : compare } ) ;
107- return params ;
108- } catch {
109- // ignore malformed q
110- }
111- }
112- writeQ ( { range : current } ) ;
113- return params ;
91+ const nextRange = { start : dateRange . startDate , end : dateRange . endDate } as IsoRange ;
92+ return writeQ ( prev , { range : nextRange , compareRange : undefined } ) ;
11493 }
11594
116- // dynamic
117- try {
118- const now = new Date ( ) ;
119- const fallback = subMonths ( now , 1 ) ;
120- let computedStart = fallback ;
121- if ( dateRange . fromNow . includes ( 'M' ) ) {
122- const match = dateRange . fromNow . match ( / - P ( \d + ) M | P - ? ( \d + ) M / i) ;
123- const months = match ? Number ( match [ 1 ] ?? match [ 2 ] ) : undefined ;
124- if ( months && months > 0 ) {
125- computedStart = subMonths ( now , months ) ;
126- }
127- } else if ( dateRange . fromNow . includes ( 'D' ) ) {
128- const match = dateRange . fromNow . match ( / - P ( \d + ) D | P - ? ( \d + ) D / i) ;
129- const days = match ? Number ( match [ 1 ] ?? match [ 2 ] ) : undefined ;
130- if ( days && days > 0 ) {
131- const ms = 24 * 60 * 60 * 1000 * days ;
132- computedStart = new Date ( now . getTime ( ) - ms ) ;
133- }
134- }
135- // preserve existing compareRange if any
136- const qv = params . get ( 'q' ) ;
137- let existingCompare : { start : string ; end : string } | null = null ;
138- if ( qv ) {
139- try {
140- const parsed = JSON . parse ( decodeBase64Url ( qv ) ) as {
141- compareRange ?: { start ?: string ; end ?: string } | null ;
142- } ;
143- if ( parsed . compareRange ?. start && parsed . compareRange ?. end ) {
144- existingCompare = {
145- start : parsed . compareRange . start ,
146- end : parsed . compareRange . end ,
147- } ;
148- }
149- } catch {
150- // ignore
151- }
152- }
153- writeQ ( {
154- range : { start : computedStart . toISOString ( ) , end : now . toISOString ( ) } ,
155- compareRange : existingCompare ,
156- } ) ;
157- } catch {
158- const d = getDefaultRange ( ) ;
159- writeQ ( { range : { start : d . start , end : d . end } } ) ;
160- }
161- return params ;
95+ const dynamicRange = computeDynamicRange ( dateRange . fromNow ) ;
96+ return writeQ ( prev , { range : dynamicRange , compareRange : undefined } ) ;
16297 } ,
16398 { replace : true } ,
16499 ) ;
165100 } ,
166- [ setSearchParams ] ,
101+ [ computeDynamicRange , setSearchParams , writeQ ] ,
167102 ) ;
168103
169104 const setCompareRange = useCallback (
170105 ( dateRange : DateRangeFilterParam ) => {
171106 setSearchParams (
172107 ( prev ) => {
173- const params = new URLSearchParams ( prev ) ;
174- const qv = params . get ( 'q' ) ;
175- const base = ( ( ) => {
176- if ( qv ) {
177- try {
178- const parsed = JSON . parse ( decodeBase64Url ( qv ) ) as {
179- range ?: { start ?: string ; end ?: string } | null ;
180- } ;
181- if ( parsed . range ?. start && parsed . range ?. end ) {
182- return { start : parsed . range . start , end : parsed . range . end } as const ;
183- }
184- } catch {
185- // ignore malformed q
186- }
187- }
188- const defaults = getDefaultRange ( ) ;
189- return { start : defaults . start , end : defaults . end } ;
190- } ) ( ) ;
191-
192- let compare : { start : string ; end : string } | null = null ;
108+ let nextCompare : IsoRange | null ;
193109 if ( ! dateRange ) {
194- compare = null ;
110+ nextCompare = null ;
195111 } else if ( dateRange . type === 'static' ) {
196- compare = { start : dateRange . startDate , end : dateRange . endDate } ;
197- } else if ( dateRange . type === 'dynamic' ) {
198- try {
199- const now = new Date ( ) ;
200- const fallback = subMonths ( now , 1 ) ;
201- let computedStart = fallback ;
202- if ( dateRange . fromNow . includes ( 'M' ) ) {
203- const match = dateRange . fromNow . match ( / - P ( \d + ) M | P - ? ( \d + ) M / i) ;
204- const months = match ? Number ( match [ 1 ] ?? match [ 2 ] ) : undefined ;
205- if ( months && months > 0 ) {
206- computedStart = subMonths ( now , months ) ;
207- }
208- } else if ( dateRange . fromNow . includes ( 'D' ) ) {
209- const match = dateRange . fromNow . match ( / - P ( \d + ) D | P - ? ( \d + ) D / i) ;
210- const days = match ? Number ( match [ 1 ] ?? match [ 2 ] ) : undefined ;
211- if ( days && days > 0 ) {
212- const ms = 24 * 60 * 60 * 1000 * days ;
213- computedStart = new Date ( now . getTime ( ) - ms ) ;
214- }
215- }
216- compare = { start : computedStart . toISOString ( ) , end : now . toISOString ( ) } ;
217- } catch {
218- compare = null ;
219- }
112+ nextCompare = { start : dateRange . startDate , end : dateRange . endDate } ;
113+ } else {
114+ nextCompare = computeDynamicRange ( dateRange . fromNow ) ;
220115 }
221116
222- const payload = {
223- range : { start : base . start , end : base . end } ,
224- compareRange : compare ,
225- } as const ;
226-
227- params . set ( 'q' , encodeBase64Url ( JSON . stringify ( payload ) ) ) ;
228- // cleanup legacy flat params if present
229- params . delete ( 'compareStart' ) ;
230- params . delete ( 'compareEnd' ) ;
231- params . delete ( 'start' ) ;
232- params . delete ( 'end' ) ;
233- return params ;
117+ return writeQ ( prev , { compareRange : nextCompare } ) ;
234118 } ,
235119 { replace : true } ,
236120 ) ;
237121 } ,
238- [ setSearchParams ] ,
122+ [ computeDynamicRange , setSearchParams , writeQ ] ,
239123 ) ;
240124
241125 return {
0 commit comments