|
1 | 1 | <script> |
2 | | - import { createEventDispatcher } from 'svelte'; |
| 2 | + import { onMount, createEventDispatcher } from 'svelte'; |
3 | 3 | import { Button, Input } from '@sveltestrap/sveltestrap'; |
4 | 4 | import Flatpickr from 'svelte-flatpickr'; |
5 | 5 | import 'flatpickr/dist/flatpickr.css'; |
6 | 6 | import { TIME_RANGE_OPTIONS, CUSTOM_DATE_RANGE } from '$lib/helpers/constants'; |
7 | 7 | import { clickoutsideDirective } from '$lib/helpers/directives'; |
8 | 8 |
|
| 9 | + // Constants |
| 10 | + const RECENT_TIME_RANGES_KEY = 'botsharp_recent_time_ranges'; |
| 11 | + const MAX_RECENT_ITEMS = 10; |
| 12 | + const TAB_RELATIVE = 'relative'; |
| 13 | + const TAB_RECENT = 'recent'; |
| 14 | + const TAB_CUSTOM = 'custom'; |
| 15 | +
|
9 | 16 | /** @type {string} */ |
10 | 17 | export let timeRange = ''; |
11 | 18 |
|
|
38 | 45 | /** @type {any} */ |
39 | 46 | let flatpickrInstance = null; |
40 | 47 |
|
| 48 | + /** @type {Array<{ startDate: string, endDate: string, label: string, timeRange?: string }>} */ |
| 49 | + let recentTimeRanges = []; |
| 50 | +
|
41 | 51 | // Format date for flatpickr (Date object to YYYY-MM-DD string) |
42 | 52 | /** @param {Date} date */ |
43 | 53 | function formatDateForFlatpickr(/** @type {Date} */ date) { |
|
68 | 78 | } |
69 | 79 | }; |
70 | 80 |
|
| 81 | + // Format time range for Recent tab display |
| 82 | + const formatRecentTimeRangeLabel = (/** @type {string} */ sDate, /** @type {string} */ eDate) => { |
| 83 | + const start = formatDateForDisplay(sDate); |
| 84 | + const end = formatDateForDisplay(eDate); |
| 85 | +
|
| 86 | + if (start === end) { |
| 87 | + return `${start}`; |
| 88 | + } else { |
| 89 | + return `${start} - ${end}`; |
| 90 | + } |
| 91 | + }; |
| 92 | +
|
71 | 93 | // Handle manual input changes |
72 | 94 | function handleStartDateChange() { |
73 | 95 | if (tempStartDate && flatpickrInstance) { |
|
113 | 135 | value: x.value |
114 | 136 | })); |
115 | 137 |
|
| 138 | + onMount(() => { |
| 139 | + loadRecentTimeRanges(); |
| 140 | + });; |
| 141 | +
|
116 | 142 | // Get today's date in YYYY-MM-DD format |
117 | 143 | const getTodayStr = () => { |
118 | 144 | const d = new Date(); |
|
151 | 177 | } |
152 | 178 | } |
153 | 179 |
|
154 | | - const dispatch = createEventDispatcher(); |
| 180 | + const dispatch = createEventDispatcher() |
155 | 181 |
|
156 | | - /** @param {string} optionValue */ |
157 | | - function handleRelativeOptionClick(/** @type {string} */ optionValue) { |
158 | | - timeRange = optionValue; |
| 182 | + function loadRecentTimeRanges() { |
| 183 | + try { |
| 184 | + const stored = localStorage.getItem(RECENT_TIME_RANGES_KEY); |
| 185 | + if (stored) { |
| 186 | + recentTimeRanges = JSON.parse(stored); |
| 187 | + } |
| 188 | + } catch (e) { |
| 189 | + recentTimeRanges = []; |
| 190 | + } |
| 191 | + } |
| 192 | +
|
| 193 | + function clearSelection() { |
| 194 | + timeRange = ''; |
159 | 195 | startDate = ''; |
160 | 196 | endDate = ''; |
161 | 197 | showDatePicker = false; |
162 | 198 | dispatch('change', { timeRange, startDate, endDate }); |
163 | 199 | } |
164 | 200 |
|
| 201 | + /** @param {any} range */ |
| 202 | + function handleRecentOptionClick(range) { |
| 203 | + if (range.timeRange) { |
| 204 | + timeRange = range.timeRange; |
| 205 | + } else { |
| 206 | + timeRange = CUSTOM_DATE_RANGE; |
| 207 | + startDate = range.startDate; |
| 208 | + endDate = range.endDate; |
| 209 | + } |
| 210 | + showDatePicker = false; |
| 211 | + dispatch('change', { timeRange, startDate, endDate }); |
| 212 | + } |
| 213 | +
|
| 214 | + /** |
| 215 | + * @param {{ startDate: string, endDate: string, timeRange?: string, label?: string }} range |
| 216 | + */ |
| 217 | + function saveRecentTimeRange(range) { |
| 218 | + try { |
| 219 | + // Use provided label or format from dates |
| 220 | + const label = range.label || formatRecentTimeRangeLabel(range.startDate, range.endDate); |
| 221 | + const newRange = { ...range, label }; |
| 222 | +
|
| 223 | + // Remove duplicate if exists (check by timeRange if it's a relative range, otherwise by dates) |
| 224 | + if (range.timeRange) { |
| 225 | + recentTimeRanges = recentTimeRanges.filter(r => r.timeRange !== range.timeRange); |
| 226 | + } else { |
| 227 | + recentTimeRanges = recentTimeRanges.filter(r => |
| 228 | + r.startDate !== range.startDate || |
| 229 | + r.endDate !== range.endDate |
| 230 | + ); |
| 231 | + } |
| 232 | +
|
| 233 | + // Add to beginning and limit to MAX_RECENT_ITEMS |
| 234 | + recentTimeRanges = [newRange, ...recentTimeRanges].slice(0, MAX_RECENT_ITEMS); |
| 235 | +
|
| 236 | + localStorage.setItem(RECENT_TIME_RANGES_KEY, JSON.stringify(recentTimeRanges)); |
| 237 | + } catch (e) { |
| 238 | + } |
| 239 | + } |
| 240 | +
|
| 241 | + /** @param {number} index */ |
| 242 | + function removeRecentTimeRange(index) { |
| 243 | + recentTimeRanges = recentTimeRanges.filter((_, idx) => idx !== index); |
| 244 | + try { |
| 245 | + localStorage.setItem(RECENT_TIME_RANGES_KEY, JSON.stringify(recentTimeRanges)); |
| 246 | + } catch (e) { |
| 247 | + } |
| 248 | + } |
| 249 | +
|
| 250 | + /** @param {string} optionValue */ |
| 251 | + function handleRelativeOptionClick(/** @type {string} */ optionValue) { |
| 252 | + // If clicking the same option, unselect it |
| 253 | + if (timeRange === optionValue) { |
| 254 | + clearSelection(); |
| 255 | + } else { |
| 256 | + timeRange = optionValue; |
| 257 | +
|
| 258 | + const option = TIME_RANGE_OPTIONS.find(x => x.value === optionValue); |
| 259 | +
|
| 260 | + if (option) { |
| 261 | + saveRecentTimeRange({ |
| 262 | + startDate: '', |
| 263 | + endDate: '', |
| 264 | + timeRange: optionValue, |
| 265 | + label: option.label |
| 266 | + }); |
| 267 | + } |
| 268 | +
|
| 269 | + startDate = ''; |
| 270 | + endDate = ''; |
| 271 | + showDatePicker = false; |
| 272 | + dispatch('change', { timeRange, startDate, endDate }); |
| 273 | + } |
| 274 | + } |
| 275 | +
|
165 | 276 | function handleApply() { |
166 | 277 | if (tempStartDate) { |
167 | 278 | const finalEndDate = tempEndDate || tempStartDate; |
168 | 279 | // Update props through binding (will trigger reactivity) |
169 | 280 | startDate = tempStartDate; |
170 | 281 | endDate = finalEndDate; |
171 | 282 | timeRange = CUSTOM_DATE_RANGE; |
| 283 | + saveRecentTimeRange({ startDate, endDate }); |
172 | 284 | // Dispatch change event with updated values |
173 | 285 | dispatch('change', { |
174 | 286 | timeRange: CUSTOM_DATE_RANGE, |
|
184 | 296 | } |
185 | 297 | </script> |
186 | 298 |
|
187 | | -<div class="position-relative"> |
| 299 | +<div class="multiselect-container" |
| 300 | + bind:this={datePickerRef} |
| 301 | + use:clickoutsideDirective |
| 302 | + on:clickoutside={(/** @type {any} */ e) => { |
| 303 | + if (e.detail && e.detail.targetNode && datePickerRef) { |
| 304 | + if (!datePickerRef.contains(e.detail.targetNode)) { |
| 305 | + showDatePicker = false; |
| 306 | + } |
| 307 | + } |
| 308 | + }}> |
188 | 309 | <button |
189 | 310 | type="button" |
190 | 311 | class="form-control text-start d-flex align-items-center justify-content-between" |
191 | 312 | on:click={() => { |
192 | 313 | showDatePicker = !showDatePicker; |
193 | 314 | if (showDatePicker) { |
194 | 315 | // If custom date is selected, switch to custom tab; otherwise use relative tab |
195 | | - datePickerTab = timeRange === CUSTOM_DATE_RANGE ? 'custom' : 'relative'; |
196 | | - if (datePickerTab === 'custom') { |
| 316 | + datePickerTab = timeRange === CUSTOM_DATE_RANGE ? TAB_CUSTOM : TAB_RELATIVE; |
| 317 | + if (datePickerTab === TAB_CUSTOM) { |
197 | 318 | // Delay init to ensure flatpickr is mounted |
198 | 319 | setTimeout(() => { |
199 | 320 | initCustomDates(); |
|
207 | 328 | <i class="bx bx-chevron-down"></i> |
208 | 329 | </button> |
209 | 330 | {#if showDatePicker} |
210 | | - <div |
211 | | - bind:this={datePickerRef} |
212 | | - use:clickoutsideDirective |
213 | | - on:clickoutside={(/** @type {any} */ e) => { |
214 | | - if (e.detail && e.detail.targetNode && datePickerRef) { |
215 | | - if (!datePickerRef.contains(e.detail.targetNode)) { |
216 | | - showDatePicker = false; |
217 | | - } |
218 | | - } |
219 | | - }} |
220 | | - class="position-absolute top-100 start-0 mt-1 bg-white border rounded shadow-lg" |
221 | | - style="z-index: 1050; min-width: 320px; max-width: 350px;" |
222 | | - > |
| 331 | + <ul class="position-absolute top-100 start-0 mt-1 bg-white border rounded shadow-lg" |
| 332 | + style="z-index: 1050; min-width: 325px; max-width: 350px;"> |
223 | 333 | <ul class="nav nav-tabs border-bottom mb-0 px-2 pt-2" role="tablist"> |
224 | | - <li class="nav-item flex-fill" role="presentation"> |
| 334 | + <li class="nav-item flex-fill d-flex justify-content-center" role="presentation"> |
225 | 335 | <button |
226 | | - class="nav-link fw-semibold {datePickerTab === 'relative' ? 'active text-primary' : 'text-muted'}" |
| 336 | + class="nav-link fw-semibold {datePickerTab === TAB_RELATIVE ? 'active text-primary' : 'text-muted'}" |
227 | 337 | type="button" |
228 | 338 | role="tab" |
229 | | - style="padding: 0.5rem 0.75rem; {datePickerTab === 'relative' ? 'border-bottom: 2px solid var(--bs-primary) !important; margin-bottom: -1px;' : ''}" |
230 | | - on:click={() => datePickerTab = 'relative'} |
| 339 | + style="padding: 0.5rem 0.75rem; {datePickerTab === TAB_RELATIVE ? 'border-bottom: 2px solid var(--bs-primary) !important; margin-bottom: -1px;' : ''}" |
| 340 | + on:click={() => datePickerTab = TAB_RELATIVE} |
231 | 341 | > |
232 | 342 | Relative |
233 | 343 | </button> |
234 | 344 | </li> |
235 | | - <li class="nav-item flex-fill" role="presentation"> |
| 345 | + <li class="nav-item flex-fill d-flex justify-content-center" role="presentation"> |
| 346 | + <button |
| 347 | + class="nav-link fw-semibold {datePickerTab === TAB_RECENT ? 'active text-primary' : 'text-muted'}" |
| 348 | + type="button" |
| 349 | + role="tab" |
| 350 | + style="padding: 0.5rem 0.75rem; {datePickerTab === TAB_RECENT ? 'border-bottom: 2px solid var(--bs-primary) !important; margin-bottom: -1px;' : ''}" |
| 351 | + on:click={() => datePickerTab = TAB_RECENT} |
| 352 | + > |
| 353 | + Recent |
| 354 | + </button> |
| 355 | + </li> |
| 356 | + <li class="nav-item flex-fill d-flex justify-content-center" role="presentation"> |
236 | 357 | <button |
237 | | - class="nav-link fw-semibold {datePickerTab === 'custom' ? 'active text-primary' : 'text-muted'}" |
| 358 | + class="nav-link fw-semibold {datePickerTab === TAB_CUSTOM ? 'active text-primary' : 'text-muted'}" |
238 | 359 | type="button" |
239 | 360 | role="tab" |
240 | | - style="padding: 0.5rem 0.75rem; {datePickerTab === 'custom' ? 'border-bottom: 2px solid var(--bs-primary) !important; margin-bottom: -1px;' : ''}" |
| 361 | + style="padding: 0.5rem 0.75rem; {datePickerTab === TAB_CUSTOM ? 'border-bottom: 2px solid var(--bs-primary) !important; margin-bottom: -1px;' : ''}" |
241 | 362 | on:click={() => { |
242 | | - datePickerTab = 'custom'; |
| 363 | + datePickerTab = TAB_CUSTOM; |
243 | 364 | // Delay init to ensure flatpickr is mounted |
244 | 365 | setTimeout(() => { |
245 | 366 | initCustomDates(); |
|
251 | 372 | </li> |
252 | 373 | </ul> |
253 | 374 |
|
254 | | - <div class="p-4"> |
255 | | - {#if datePickerTab === 'relative'} |
256 | | - <div class="d-flex flex-column gap-2" style="max-height: 300px; overflow-y: auto;"> |
| 375 | + <div class="p-2"> |
| 376 | + {#if datePickerTab === TAB_RELATIVE} |
| 377 | + <button |
| 378 | + type="button" |
| 379 | + class="clear-btn text-center d-flex align-items-center justify-content-center w-100" |
| 380 | + on:click|preventDefault|stopPropagation={() => { |
| 381 | + clearSelection(); |
| 382 | + }} |
| 383 | + > |
| 384 | + <span class="text-secondary"> |
| 385 | + {`Clear selection`} |
| 386 | + </span> |
| 387 | + </button> |
| 388 | + <div class="d-flex flex-column" |
| 389 | + style="max-height: 300px; overflow-y: auto;" |
| 390 | + > |
257 | 391 | {#each presetTimeRangeOptions as option} |
258 | 392 | <button |
259 | 393 | type="button" |
260 | | - class="btn btn-sm btn-outline-secondary text-start {timeRange === option.value ? 'active' : ''}" |
| 394 | + class="btn relative-option text-start d-flex align-items-center {timeRange === option.value ? 'active' : ''}" |
261 | 395 | on:click={(e) => { |
262 | 396 | e.preventDefault(); |
263 | 397 | e.stopPropagation(); |
264 | 398 | handleRelativeOptionClick(option.value); |
265 | 399 | }} |
266 | 400 | > |
267 | | - {option.label} |
| 401 | + <i class="bx bx-check me-2" style="visibility: {timeRange === option.value ? 'visible' : 'hidden'}; font-size: 1.2rem;"></i> |
| 402 | + <span>{option.label}</span> |
268 | 403 | </button> |
269 | 404 | {/each} |
270 | 405 | </div> |
271 | | - {:else if datePickerTab === 'custom'} |
| 406 | + {:else if datePickerTab === TAB_RECENT} |
| 407 | + {#if recentTimeRanges.length === 0} |
| 408 | + <div class="text-muted text-center py-3 w-100"> |
| 409 | + <i class="bx bx-time-five mb-2" style="font-size: 1.5rem;"></i> |
| 410 | + <p class="mb-0 small">{'No recent time ranges'}</p> |
| 411 | + </div> |
| 412 | + {:else} |
| 413 | + {#each recentTimeRanges as range, index} |
| 414 | + <!-- svelte-ignore a11y-click-events-have-key-events --> |
| 415 | + <!-- svelte-ignore a11y-no-static-element-interactions --> |
| 416 | + <div |
| 417 | + class="option-item clickable d-flex justify-content-between" |
| 418 | + role="button" |
| 419 | + tabindex="0" |
| 420 | + on:click|preventDefault|stopPropagation={() => { |
| 421 | + handleRecentOptionClick(range); |
| 422 | + }} |
| 423 | + > |
| 424 | + <div class="line-align-center"> |
| 425 | + {range.label} |
| 426 | + </div> |
| 427 | + <button |
| 428 | + type="button" |
| 429 | + class="btn btn-sm btn-link text-muted p-0" |
| 430 | + title="Remove from recent" |
| 431 | + on:click|preventDefault|stopPropagation={() => { |
| 432 | + removeRecentTimeRange(index); |
| 433 | + }} |
| 434 | + > |
| 435 | + <i class="bx bx-x"></i> |
| 436 | + </button> |
| 437 | + </div> |
| 438 | + {/each} |
| 439 | + {/if} |
| 440 | + {:else if datePickerTab === TAB_CUSTOM} |
272 | 441 | <!-- Calendar Grid --> |
273 | 442 | <div class="mb-3"> |
274 | 443 | <Flatpickr |
|
322 | 491 | </div> |
323 | 492 | {/if} |
324 | 493 | </div> |
325 | | - </div> |
| 494 | + </ul> |
326 | 495 | {/if} |
327 | 496 | </div> |
328 | 497 |
|
|
331 | 500 | :global(.flatpickr-input) { |
332 | 501 | display: none !important; |
333 | 502 | } |
| 503 | +
|
| 504 | + .clear-btn { |
| 505 | + background-color: transparent; |
| 506 | + border: none; |
| 507 | + padding: 0.5rem 0.75rem; |
| 508 | + transition: background-color 0.15s ease-in-out; |
| 509 | + } |
| 510 | +
|
| 511 | + .clear-btn:hover { |
| 512 | + background-color: aliceblue; |
| 513 | + } |
| 514 | +
|
| 515 | + /* Hover and active styles for relative dropdown items */ |
| 516 | + .relative-option { |
| 517 | + background-color: transparent; |
| 518 | + border: none; |
| 519 | + transition: background-color 0.15s ease-in-out; |
| 520 | + } |
| 521 | +
|
| 522 | + .relative-option:hover { |
| 523 | + background-color: aliceblue; |
| 524 | + } |
| 525 | +
|
| 526 | + .relative-option.active { |
| 527 | + background-color: transparent; |
| 528 | + color: inherit; |
| 529 | + } |
| 530 | +
|
| 531 | + .relative-option.active:hover { |
| 532 | + background-color: aliceblue; |
| 533 | + } |
334 | 534 | </style> |
0 commit comments