thread_history.rs

  1use crate::acp::AcpThreadView;
  2use crate::{AgentPanel, RemoveSelectedThread};
  3use agent2::{HistoryEntry, HistoryStore};
  4use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
  5use editor::{Editor, EditorEvent};
  6use fuzzy::StringMatchCandidate;
  7use gpui::{
  8    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
  9    UniformListScrollHandle, WeakEntity, Window, uniform_list,
 10};
 11use std::{fmt::Display, ops::Range};
 12use text::Bias;
 13use time::{OffsetDateTime, UtcOffset};
 14use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, WithScrollbar, prelude::*};
 15
 16pub struct AcpThreadHistory {
 17    pub(crate) history_store: Entity<HistoryStore>,
 18    scroll_handle: UniformListScrollHandle,
 19    selected_index: usize,
 20    hovered_index: Option<usize>,
 21    search_editor: Entity<Editor>,
 22    search_query: SharedString,
 23
 24    visible_items: Vec<ListItemType>,
 25
 26    local_timezone: UtcOffset,
 27
 28    _update_task: Task<()>,
 29    _subscriptions: Vec<gpui::Subscription>,
 30}
 31
 32enum ListItemType {
 33    BucketSeparator(TimeBucket),
 34    Entry {
 35        entry: HistoryEntry,
 36        format: EntryTimeFormat,
 37    },
 38    SearchResult {
 39        entry: HistoryEntry,
 40        positions: Vec<usize>,
 41    },
 42}
 43
 44impl ListItemType {
 45    fn history_entry(&self) -> Option<&HistoryEntry> {
 46        match self {
 47            ListItemType::Entry { entry, .. } => Some(entry),
 48            ListItemType::SearchResult { entry, .. } => Some(entry),
 49            _ => None,
 50        }
 51    }
 52}
 53
 54pub enum ThreadHistoryEvent {
 55    Open(HistoryEntry),
 56}
 57
 58impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
 59
 60impl AcpThreadHistory {
 61    pub(crate) fn new(
 62        history_store: Entity<agent2::HistoryStore>,
 63        window: &mut Window,
 64        cx: &mut Context<Self>,
 65    ) -> Self {
 66        let search_editor = cx.new(|cx| {
 67            let mut editor = Editor::single_line(window, cx);
 68            editor.set_placeholder_text("Search threads...", window, cx);
 69            editor
 70        });
 71
 72        let search_editor_subscription =
 73            cx.subscribe(&search_editor, |this, search_editor, event, cx| {
 74                if let EditorEvent::BufferEdited = event {
 75                    let query = search_editor.read(cx).text(cx);
 76                    if this.search_query != query {
 77                        this.search_query = query.into();
 78                        this.update_visible_items(false, cx);
 79                    }
 80                }
 81            });
 82
 83        let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
 84            this.update_visible_items(true, cx);
 85        });
 86
 87        let scroll_handle = UniformListScrollHandle::default();
 88
 89        let mut this = Self {
 90            history_store,
 91            scroll_handle,
 92            selected_index: 0,
 93            hovered_index: None,
 94            visible_items: Default::default(),
 95            search_editor,
 96            local_timezone: UtcOffset::from_whole_seconds(
 97                chrono::Local::now().offset().local_minus_utc(),
 98            )
 99            .unwrap(),
100            search_query: SharedString::default(),
101            _subscriptions: vec![search_editor_subscription, history_store_subscription],
102            _update_task: Task::ready(()),
103        };
104        this.update_visible_items(false, cx);
105        this
106    }
107
108    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
109        let entries = self
110            .history_store
111            .update(cx, |store, _| store.entries().collect());
112        let new_list_items = if self.search_query.is_empty() {
113            self.add_list_separators(entries, cx)
114        } else {
115            self.filter_search_results(entries, cx)
116        };
117        let selected_history_entry = if preserve_selected_item {
118            self.selected_history_entry().cloned()
119        } else {
120            None
121        };
122
123        self._update_task = cx.spawn(async move |this, cx| {
124            let new_visible_items = new_list_items.await;
125            this.update(cx, |this, cx| {
126                let new_selected_index = if let Some(history_entry) = selected_history_entry {
127                    let history_entry_id = history_entry.id();
128                    new_visible_items
129                        .iter()
130                        .position(|visible_entry| {
131                            visible_entry
132                                .history_entry()
133                                .is_some_and(|entry| entry.id() == history_entry_id)
134                        })
135                        .unwrap_or(0)
136                } else {
137                    0
138                };
139
140                this.visible_items = new_visible_items;
141                this.set_selected_index(new_selected_index, Bias::Right, cx);
142                cx.notify();
143            })
144            .ok();
145        });
146    }
147
148    fn add_list_separators(&self, entries: Vec<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
149        cx.background_spawn(async move {
150            let mut items = Vec::with_capacity(entries.len() + 1);
151            let mut bucket = None;
152            let today = Local::now().naive_local().date();
153
154            for entry in entries.into_iter() {
155                let entry_date = entry
156                    .updated_at()
157                    .with_timezone(&Local)
158                    .naive_local()
159                    .date();
160                let entry_bucket = TimeBucket::from_dates(today, entry_date);
161
162                if Some(entry_bucket) != bucket {
163                    bucket = Some(entry_bucket);
164                    items.push(ListItemType::BucketSeparator(entry_bucket));
165                }
166
167                items.push(ListItemType::Entry {
168                    entry,
169                    format: entry_bucket.into(),
170                });
171            }
172            items
173        })
174    }
175
176    fn filter_search_results(
177        &self,
178        entries: Vec<HistoryEntry>,
179        cx: &App,
180    ) -> Task<Vec<ListItemType>> {
181        let query = self.search_query.clone();
182        cx.background_spawn({
183            let executor = cx.background_executor().clone();
184            async move {
185                let mut candidates = Vec::with_capacity(entries.len());
186
187                for (idx, entry) in entries.iter().enumerate() {
188                    candidates.push(StringMatchCandidate::new(idx, entry.title()));
189                }
190
191                const MAX_MATCHES: usize = 100;
192
193                let matches = fuzzy::match_strings(
194                    &candidates,
195                    &query,
196                    false,
197                    true,
198                    MAX_MATCHES,
199                    &Default::default(),
200                    executor,
201                )
202                .await;
203
204                matches
205                    .into_iter()
206                    .map(|search_match| ListItemType::SearchResult {
207                        entry: entries[search_match.candidate_id].clone(),
208                        positions: search_match.positions,
209                    })
210                    .collect()
211            }
212        })
213    }
214
215    fn search_produced_no_matches(&self) -> bool {
216        self.visible_items.is_empty() && !self.search_query.is_empty()
217    }
218
219    fn selected_history_entry(&self) -> Option<&HistoryEntry> {
220        self.get_history_entry(self.selected_index)
221    }
222
223    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> {
224        self.visible_items.get(visible_items_ix)?.history_entry()
225    }
226
227    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
228        if self.visible_items.len() == 0 {
229            self.selected_index = 0;
230            return;
231        }
232        while matches!(
233            self.visible_items.get(index),
234            None | Some(ListItemType::BucketSeparator(..))
235        ) {
236            index = match bias {
237                Bias::Left => {
238                    if index == 0 {
239                        self.visible_items.len() - 1
240                    } else {
241                        index - 1
242                    }
243                }
244                Bias::Right => {
245                    if index >= self.visible_items.len() - 1 {
246                        0
247                    } else {
248                        index + 1
249                    }
250                }
251            };
252        }
253        self.selected_index = index;
254        self.scroll_handle
255            .scroll_to_item(index, ScrollStrategy::Top);
256        cx.notify()
257    }
258
259    pub fn select_previous(
260        &mut self,
261        _: &menu::SelectPrevious,
262        _window: &mut Window,
263        cx: &mut Context<Self>,
264    ) {
265        if self.selected_index == 0 {
266            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
267        } else {
268            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
269        }
270    }
271
272    pub fn select_next(
273        &mut self,
274        _: &menu::SelectNext,
275        _window: &mut Window,
276        cx: &mut Context<Self>,
277    ) {
278        if self.selected_index == self.visible_items.len() - 1 {
279            self.set_selected_index(0, Bias::Right, cx);
280        } else {
281            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
282        }
283    }
284
285    fn select_first(
286        &mut self,
287        _: &menu::SelectFirst,
288        _window: &mut Window,
289        cx: &mut Context<Self>,
290    ) {
291        self.set_selected_index(0, Bias::Right, cx);
292    }
293
294    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
295        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
296    }
297
298    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
299        self.confirm_entry(self.selected_index, cx);
300    }
301
302    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
303        let Some(entry) = self.get_history_entry(ix) else {
304            return;
305        };
306        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
307    }
308
309    fn remove_selected_thread(
310        &mut self,
311        _: &RemoveSelectedThread,
312        _window: &mut Window,
313        cx: &mut Context<Self>,
314    ) {
315        self.remove_thread(self.selected_index, cx)
316    }
317
318    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
319        let Some(entry) = self.get_history_entry(visible_item_ix) else {
320            return;
321        };
322
323        let task = match entry {
324            HistoryEntry::AcpThread(thread) => self
325                .history_store
326                .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
327            HistoryEntry::TextThread(context) => self.history_store.update(cx, |this, cx| {
328                this.delete_text_thread(context.path.clone(), cx)
329            }),
330        };
331        task.detach_and_log_err(cx);
332    }
333
334    fn render_list_items(
335        &mut self,
336        range: Range<usize>,
337        _window: &mut Window,
338        cx: &mut Context<Self>,
339    ) -> Vec<AnyElement> {
340        self.visible_items
341            .get(range.clone())
342            .into_iter()
343            .flatten()
344            .enumerate()
345            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
346            .collect()
347    }
348
349    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
350        match item {
351            ListItemType::Entry { entry, format } => self
352                .render_history_entry(entry, *format, ix, Vec::default(), cx)
353                .into_any(),
354            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
355                entry,
356                EntryTimeFormat::DateAndTime,
357                ix,
358                positions.clone(),
359                cx,
360            ),
361            ListItemType::BucketSeparator(bucket) => div()
362                .px(DynamicSpacing::Base06.rems(cx))
363                .pt_2()
364                .pb_1()
365                .child(
366                    Label::new(bucket.to_string())
367                        .size(LabelSize::XSmall)
368                        .color(Color::Muted),
369                )
370                .into_any_element(),
371        }
372    }
373
374    fn render_history_entry(
375        &self,
376        entry: &HistoryEntry,
377        format: EntryTimeFormat,
378        ix: usize,
379        highlight_positions: Vec<usize>,
380        cx: &Context<Self>,
381    ) -> AnyElement {
382        let selected = ix == self.selected_index;
383        let hovered = Some(ix) == self.hovered_index;
384        let timestamp = entry.updated_at().timestamp();
385        let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
386
387        h_flex()
388            .w_full()
389            .pb_1()
390            .child(
391                ListItem::new(ix)
392                    .rounded()
393                    .toggle_state(selected)
394                    .spacing(ListItemSpacing::Sparse)
395                    .start_slot(
396                        h_flex()
397                            .w_full()
398                            .gap_2()
399                            .justify_between()
400                            .child(
401                                HighlightedLabel::new(entry.title(), highlight_positions)
402                                    .size(LabelSize::Small)
403                                    .truncate(),
404                            )
405                            .child(
406                                Label::new(thread_timestamp)
407                                    .color(Color::Muted)
408                                    .size(LabelSize::XSmall),
409                            ),
410                    )
411                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
412                        if *is_hovered {
413                            this.hovered_index = Some(ix);
414                        } else if this.hovered_index == Some(ix) {
415                            this.hovered_index = None;
416                        }
417
418                        cx.notify();
419                    }))
420                    .end_slot::<IconButton>(if hovered {
421                        Some(
422                            IconButton::new("delete", IconName::Trash)
423                                .icon_size(IconSize::XSmall)
424                                .icon_color(Color::Muted)
425                                .tooltip(move |window, cx| {
426                                    Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
427                                })
428                                .on_click(
429                                    cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)),
430                                ),
431                        )
432                    } else {
433                        None
434                    })
435                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
436            )
437            .into_any_element()
438    }
439}
440
441impl Focusable for AcpThreadHistory {
442    fn focus_handle(&self, cx: &App) -> FocusHandle {
443        self.search_editor.focus_handle(cx)
444    }
445}
446
447impl Render for AcpThreadHistory {
448    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
449        v_flex()
450            .key_context("ThreadHistory")
451            .size_full()
452            .on_action(cx.listener(Self::select_previous))
453            .on_action(cx.listener(Self::select_next))
454            .on_action(cx.listener(Self::select_first))
455            .on_action(cx.listener(Self::select_last))
456            .on_action(cx.listener(Self::confirm))
457            .on_action(cx.listener(Self::remove_selected_thread))
458            .when(!self.history_store.read(cx).is_empty(cx), |parent| {
459                parent.child(
460                    h_flex()
461                        .h(px(41.)) // Match the toolbar perfectly
462                        .w_full()
463                        .py_1()
464                        .px_2()
465                        .gap_2()
466                        .justify_between()
467                        .border_b_1()
468                        .border_color(cx.theme().colors().border)
469                        .child(
470                            Icon::new(IconName::MagnifyingGlass)
471                                .color(Color::Muted)
472                                .size(IconSize::Small),
473                        )
474                        .child(self.search_editor.clone()),
475                )
476            })
477            .child({
478                let view = v_flex()
479                    .id("list-container")
480                    .relative()
481                    .overflow_hidden()
482                    .flex_grow();
483
484                if self.history_store.read(cx).is_empty(cx) {
485                    view.justify_center()
486                        .child(
487                            h_flex().w_full().justify_center().child(
488                                Label::new("You don't have any past threads yet.")
489                                    .size(LabelSize::Small),
490                            ),
491                        )
492                } else if self.search_produced_no_matches() {
493                    view.justify_center().child(
494                        h_flex().w_full().justify_center().child(
495                            Label::new("No threads match your search.").size(LabelSize::Small),
496                        ),
497                    )
498                } else {
499                    view.child(
500                        uniform_list(
501                            "thread-history",
502                            self.visible_items.len(),
503                            cx.processor(|this, range: Range<usize>, window, cx| {
504                                this.render_list_items(range, window, cx)
505                            }),
506                        )
507                        .p_1()
508                        .pr_4()
509                        .track_scroll(self.scroll_handle.clone())
510                        .flex_grow(),
511                    )
512                    .vertical_scrollbar_for(
513                        self.scroll_handle.clone(),
514                        window,
515                        cx,
516                    )
517                }
518            })
519    }
520}
521
522#[derive(IntoElement)]
523pub struct AcpHistoryEntryElement {
524    entry: HistoryEntry,
525    thread_view: WeakEntity<AcpThreadView>,
526    selected: bool,
527    hovered: bool,
528    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
529}
530
531impl AcpHistoryEntryElement {
532    pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> Self {
533        Self {
534            entry,
535            thread_view,
536            selected: false,
537            hovered: false,
538            on_hover: Box::new(|_, _, _| {}),
539        }
540    }
541
542    pub fn hovered(mut self, hovered: bool) -> Self {
543        self.hovered = hovered;
544        self
545    }
546
547    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
548        self.on_hover = Box::new(on_hover);
549        self
550    }
551}
552
553impl RenderOnce for AcpHistoryEntryElement {
554    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
555        let id = self.entry.id();
556        let title = self.entry.title();
557        let timestamp = self.entry.updated_at();
558
559        let formatted_time = {
560            let now = chrono::Utc::now();
561            let duration = now.signed_duration_since(timestamp);
562
563            if duration.num_days() > 0 {
564                format!("{}d", duration.num_days())
565            } else if duration.num_hours() > 0 {
566                format!("{}h ago", duration.num_hours())
567            } else if duration.num_minutes() > 0 {
568                format!("{}m ago", duration.num_minutes())
569            } else {
570                "Just now".to_string()
571            }
572        };
573
574        ListItem::new(id)
575            .rounded()
576            .toggle_state(self.selected)
577            .spacing(ListItemSpacing::Sparse)
578            .start_slot(
579                h_flex()
580                    .w_full()
581                    .gap_2()
582                    .justify_between()
583                    .child(Label::new(title).size(LabelSize::Small).truncate())
584                    .child(
585                        Label::new(formatted_time)
586                            .color(Color::Muted)
587                            .size(LabelSize::XSmall),
588                    ),
589            )
590            .on_hover(self.on_hover)
591            .end_slot::<IconButton>(if self.hovered || self.selected {
592                Some(
593                    IconButton::new("delete", IconName::Trash)
594                        .icon_size(IconSize::XSmall)
595                        .icon_color(Color::Muted)
596                        .tooltip(move |window, cx| {
597                            Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
598                        })
599                        .on_click({
600                            let thread_view = self.thread_view.clone();
601                            let entry = self.entry.clone();
602
603                            move |_event, _window, cx| {
604                                if let Some(thread_view) = thread_view.upgrade() {
605                                    thread_view.update(cx, |thread_view, cx| {
606                                        thread_view.delete_history_entry(entry.clone(), cx);
607                                    });
608                                }
609                            }
610                        }),
611                )
612            } else {
613                None
614            })
615            .on_click({
616                let thread_view = self.thread_view.clone();
617                let entry = self.entry;
618
619                move |_event, window, cx| {
620                    if let Some(workspace) = thread_view
621                        .upgrade()
622                        .and_then(|view| view.read(cx).workspace().upgrade())
623                    {
624                        match &entry {
625                            HistoryEntry::AcpThread(thread_metadata) => {
626                                if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
627                                    panel.update(cx, |panel, cx| {
628                                        panel.load_agent_thread(
629                                            thread_metadata.clone(),
630                                            window,
631                                            cx,
632                                        );
633                                    });
634                                }
635                            }
636                            HistoryEntry::TextThread(context) => {
637                                if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
638                                    panel.update(cx, |panel, cx| {
639                                        panel
640                                            .open_saved_prompt_editor(
641                                                context.path.clone(),
642                                                window,
643                                                cx,
644                                            )
645                                            .detach_and_log_err(cx);
646                                    });
647                                }
648                            }
649                        }
650                    }
651                }
652            })
653    }
654}
655
656#[derive(Clone, Copy)]
657pub enum EntryTimeFormat {
658    DateAndTime,
659    TimeOnly,
660}
661
662impl EntryTimeFormat {
663    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
664        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
665
666        match self {
667            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
668                timestamp,
669                OffsetDateTime::now_utc(),
670                timezone,
671                time_format::TimestampFormat::EnhancedAbsolute,
672            ),
673            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
674        }
675    }
676}
677
678impl From<TimeBucket> for EntryTimeFormat {
679    fn from(bucket: TimeBucket) -> Self {
680        match bucket {
681            TimeBucket::Today => EntryTimeFormat::TimeOnly,
682            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
683            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
684            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
685            TimeBucket::All => EntryTimeFormat::DateAndTime,
686        }
687    }
688}
689
690#[derive(PartialEq, Eq, Clone, Copy, Debug)]
691enum TimeBucket {
692    Today,
693    Yesterday,
694    ThisWeek,
695    PastWeek,
696    All,
697}
698
699impl TimeBucket {
700    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
701        if date == reference {
702            return TimeBucket::Today;
703        }
704
705        if date == reference - TimeDelta::days(1) {
706            return TimeBucket::Yesterday;
707        }
708
709        let week = date.iso_week();
710
711        if reference.iso_week() == week {
712            return TimeBucket::ThisWeek;
713        }
714
715        let last_week = (reference - TimeDelta::days(7)).iso_week();
716
717        if week == last_week {
718            return TimeBucket::PastWeek;
719        }
720
721        TimeBucket::All
722    }
723}
724
725impl Display for TimeBucket {
726    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727        match self {
728            TimeBucket::Today => write!(f, "Today"),
729            TimeBucket::Yesterday => write!(f, "Yesterday"),
730            TimeBucket::ThisWeek => write!(f, "This Week"),
731            TimeBucket::PastWeek => write!(f, "Past Week"),
732            TimeBucket::All => write!(f, "All"),
733        }
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use chrono::NaiveDate;
741
742    #[test]
743    fn test_time_bucket_from_dates() {
744        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
745
746        let date = today;
747        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
748
749        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
750        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
751
752        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
753        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
754
755        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
756        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
757
758        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
759        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
760
761        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
762        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
763
764        // All: not in this week or last week
765        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
766        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
767
768        // Test year boundary cases
769        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
770
771        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
772        assert_eq!(
773            TimeBucket::from_dates(new_year, date),
774            TimeBucket::Yesterday
775        );
776
777        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
778        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
779    }
780}