thread_history.rs

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