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