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