thread_history.rs

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