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