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