thread_history.rs

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