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