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