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