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}