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