1use crate::acp::AcpThreadView;
2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
3use agent::{HistoryEntry, HistoryStore};
4use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
5use editor::{Editor, EditorEvent};
6use fuzzy::StringMatchCandidate;
7use gpui::{
8 App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, 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, Tab, Tooltip, WithScrollbar,
16 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 visible_items: Vec<ListItemType>,
27 local_timezone: UtcOffset,
28 confirming_delete_history: bool,
29 _update_task: Task<()>,
30 _subscriptions: Vec<gpui::Subscription>,
31}
32
33enum ListItemType {
34 BucketSeparator(TimeBucket),
35 Entry {
36 entry: HistoryEntry,
37 format: EntryTimeFormat,
38 },
39 SearchResult {
40 entry: HistoryEntry,
41 positions: Vec<usize>,
42 },
43}
44
45impl ListItemType {
46 fn history_entry(&self) -> Option<&HistoryEntry> {
47 match self {
48 ListItemType::Entry { entry, .. } => Some(entry),
49 ListItemType::SearchResult { entry, .. } => Some(entry),
50 _ => None,
51 }
52 }
53}
54
55pub enum ThreadHistoryEvent {
56 Open(HistoryEntry),
57}
58
59impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
60
61impl AcpThreadHistory {
62 pub(crate) fn new(
63 history_store: Entity<agent::HistoryStore>,
64 window: &mut Window,
65 cx: &mut Context<Self>,
66 ) -> Self {
67 let search_editor = cx.new(|cx| {
68 let mut editor = Editor::single_line(window, cx);
69 editor.set_placeholder_text("Search threads...", window, cx);
70 editor
71 });
72
73 let search_editor_subscription =
74 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
75 if let EditorEvent::BufferEdited = event {
76 let query = search_editor.read(cx).text(cx);
77 if this.search_query != query {
78 this.search_query = query.into();
79 this.update_visible_items(false, cx);
80 }
81 }
82 });
83
84 let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
85 this.update_visible_items(true, cx);
86 });
87
88 let scroll_handle = UniformListScrollHandle::default();
89
90 let mut this = Self {
91 history_store,
92 scroll_handle,
93 selected_index: 0,
94 hovered_index: None,
95 visible_items: Default::default(),
96 search_editor,
97 local_timezone: UtcOffset::from_whole_seconds(
98 chrono::Local::now().offset().local_minus_utc(),
99 )
100 .unwrap(),
101 search_query: SharedString::default(),
102 confirming_delete_history: false,
103 _subscriptions: vec![search_editor_subscription, history_store_subscription],
104 _update_task: Task::ready(()),
105 };
106 this.update_visible_items(false, cx);
107 this
108 }
109
110 fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
111 let entries = self
112 .history_store
113 .update(cx, |store, _| store.entries().collect());
114 let new_list_items = if self.search_query.is_empty() {
115 self.add_list_separators(entries, cx)
116 } else {
117 self.filter_search_results(entries, cx)
118 };
119 let selected_history_entry = if preserve_selected_item {
120 self.selected_history_entry().cloned()
121 } else {
122 None
123 };
124
125 self._update_task = cx.spawn(async move |this, cx| {
126 let new_visible_items = new_list_items.await;
127 this.update(cx, |this, cx| {
128 let new_selected_index = if let Some(history_entry) = selected_history_entry {
129 let history_entry_id = history_entry.id();
130 new_visible_items
131 .iter()
132 .position(|visible_entry| {
133 visible_entry
134 .history_entry()
135 .is_some_and(|entry| entry.id() == history_entry_id)
136 })
137 .unwrap_or(0)
138 } else {
139 0
140 };
141
142 this.visible_items = new_visible_items;
143 this.set_selected_index(new_selected_index, Bias::Right, cx);
144 cx.notify();
145 })
146 .ok();
147 });
148 }
149
150 fn add_list_separators(&self, entries: Vec<HistoryEntry>, cx: &App) -> Task<Vec<ListItemType>> {
151 cx.background_spawn(async move {
152 let mut items = Vec::with_capacity(entries.len() + 1);
153 let mut bucket = None;
154 let today = Local::now().naive_local().date();
155
156 for entry in entries.into_iter() {
157 let entry_date = entry
158 .updated_at()
159 .with_timezone(&Local)
160 .naive_local()
161 .date();
162 let entry_bucket = TimeBucket::from_dates(today, entry_date);
163
164 if Some(entry_bucket) != bucket {
165 bucket = Some(entry_bucket);
166 items.push(ListItemType::BucketSeparator(entry_bucket));
167 }
168
169 items.push(ListItemType::Entry {
170 entry,
171 format: entry_bucket.into(),
172 });
173 }
174 items
175 })
176 }
177
178 fn filter_search_results(
179 &self,
180 entries: Vec<HistoryEntry>,
181 cx: &App,
182 ) -> Task<Vec<ListItemType>> {
183 let query = self.search_query.clone();
184 cx.background_spawn({
185 let executor = cx.background_executor().clone();
186 async move {
187 let mut candidates = Vec::with_capacity(entries.len());
188
189 for (idx, entry) in entries.iter().enumerate() {
190 candidates.push(StringMatchCandidate::new(idx, entry.title()));
191 }
192
193 const MAX_MATCHES: usize = 100;
194
195 let matches = fuzzy::match_strings(
196 &candidates,
197 &query,
198 false,
199 true,
200 MAX_MATCHES,
201 &Default::default(),
202 executor,
203 )
204 .await;
205
206 matches
207 .into_iter()
208 .map(|search_match| ListItemType::SearchResult {
209 entry: entries[search_match.candidate_id].clone(),
210 positions: search_match.positions,
211 })
212 .collect()
213 }
214 })
215 }
216
217 fn search_produced_no_matches(&self) -> bool {
218 self.visible_items.is_empty() && !self.search_query.is_empty()
219 }
220
221 fn selected_history_entry(&self) -> Option<&HistoryEntry> {
222 self.get_history_entry(self.selected_index)
223 }
224
225 fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> {
226 self.visible_items.get(visible_items_ix)?.history_entry()
227 }
228
229 fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
230 if self.visible_items.len() == 0 {
231 self.selected_index = 0;
232 return;
233 }
234 while matches!(
235 self.visible_items.get(index),
236 None | Some(ListItemType::BucketSeparator(..))
237 ) {
238 index = match bias {
239 Bias::Left => {
240 if index == 0 {
241 self.visible_items.len() - 1
242 } else {
243 index - 1
244 }
245 }
246 Bias::Right => {
247 if index >= self.visible_items.len() - 1 {
248 0
249 } else {
250 index + 1
251 }
252 }
253 };
254 }
255 self.selected_index = index;
256 self.scroll_handle
257 .scroll_to_item(index, ScrollStrategy::Top);
258 cx.notify()
259 }
260
261 pub fn select_previous(
262 &mut self,
263 _: &menu::SelectPrevious,
264 _window: &mut Window,
265 cx: &mut Context<Self>,
266 ) {
267 if self.selected_index == 0 {
268 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
269 } else {
270 self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
271 }
272 }
273
274 pub fn select_next(
275 &mut self,
276 _: &menu::SelectNext,
277 _window: &mut Window,
278 cx: &mut Context<Self>,
279 ) {
280 if self.selected_index == self.visible_items.len() - 1 {
281 self.set_selected_index(0, Bias::Right, cx);
282 } else {
283 self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
284 }
285 }
286
287 fn select_first(
288 &mut self,
289 _: &menu::SelectFirst,
290 _window: &mut Window,
291 cx: &mut Context<Self>,
292 ) {
293 self.set_selected_index(0, Bias::Right, cx);
294 }
295
296 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
297 self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
298 }
299
300 fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
301 self.confirm_entry(self.selected_index, cx);
302 }
303
304 fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
305 let Some(entry) = self.get_history_entry(ix) else {
306 return;
307 };
308 cx.emit(ThreadHistoryEvent::Open(entry.clone()));
309 }
310
311 fn remove_selected_thread(
312 &mut self,
313 _: &RemoveSelectedThread,
314 _window: &mut Window,
315 cx: &mut Context<Self>,
316 ) {
317 self.remove_thread(self.selected_index, cx)
318 }
319
320 fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
321 let Some(entry) = self.get_history_entry(visible_item_ix) else {
322 return;
323 };
324
325 let task = match entry {
326 HistoryEntry::AcpThread(thread) => self
327 .history_store
328 .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)),
329 HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| {
330 this.delete_text_thread(text_thread.path.clone(), cx)
331 }),
332 };
333 task.detach_and_log_err(cx);
334 }
335
336 fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
337 self.history_store.update(cx, |store, cx| {
338 store.delete_threads(cx).detach_and_log_err(cx)
339 });
340 self.confirming_delete_history = false;
341 cx.notify();
342 }
343
344 fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
345 self.confirming_delete_history = true;
346 cx.notify();
347 }
348
349 fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
350 self.confirming_delete_history = false;
351 cx.notify();
352 }
353
354 fn render_list_items(
355 &mut self,
356 range: Range<usize>,
357 _window: &mut Window,
358 cx: &mut Context<Self>,
359 ) -> Vec<AnyElement> {
360 self.visible_items
361 .get(range.clone())
362 .into_iter()
363 .flatten()
364 .enumerate()
365 .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
366 .collect()
367 }
368
369 fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
370 match item {
371 ListItemType::Entry { entry, format } => self
372 .render_history_entry(entry, *format, ix, Vec::default(), cx)
373 .into_any(),
374 ListItemType::SearchResult { entry, positions } => self.render_history_entry(
375 entry,
376 EntryTimeFormat::DateAndTime,
377 ix,
378 positions.clone(),
379 cx,
380 ),
381 ListItemType::BucketSeparator(bucket) => div()
382 .px(DynamicSpacing::Base06.rems(cx))
383 .pt_2()
384 .pb_1()
385 .child(
386 Label::new(bucket.to_string())
387 .size(LabelSize::XSmall)
388 .color(Color::Muted),
389 )
390 .into_any_element(),
391 }
392 }
393
394 fn render_history_entry(
395 &self,
396 entry: &HistoryEntry,
397 format: EntryTimeFormat,
398 ix: usize,
399 highlight_positions: Vec<usize>,
400 cx: &Context<Self>,
401 ) -> AnyElement {
402 let selected = ix == self.selected_index;
403 let hovered = Some(ix) == self.hovered_index;
404 let timestamp = entry.updated_at().timestamp();
405
406 let display_text = match format {
407 EntryTimeFormat::DateAndTime => {
408 let entry_time = entry.updated_at();
409 let now = Utc::now();
410 let duration = now.signed_duration_since(entry_time);
411 let days = duration.num_days();
412
413 format!("{}d", days)
414 }
415 EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
416 };
417
418 let title = entry.title().clone();
419 let full_date =
420 EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
421
422 h_flex()
423 .w_full()
424 .pb_1()
425 .child(
426 ListItem::new(ix)
427 .rounded()
428 .toggle_state(selected)
429 .spacing(ListItemSpacing::Sparse)
430 .start_slot(
431 h_flex()
432 .w_full()
433 .gap_2()
434 .justify_between()
435 .child(
436 HighlightedLabel::new(entry.title(), highlight_positions)
437 .size(LabelSize::Small)
438 .truncate(),
439 )
440 .child(
441 Label::new(display_text)
442 .color(Color::Muted)
443 .size(LabelSize::XSmall),
444 ),
445 )
446 .tooltip(move |_, cx| {
447 Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
448 })
449 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
450 if *is_hovered {
451 this.hovered_index = Some(ix);
452 } else if this.hovered_index == Some(ix) {
453 this.hovered_index = None;
454 }
455
456 cx.notify();
457 }))
458 .end_slot::<IconButton>(if hovered {
459 Some(
460 IconButton::new("delete", IconName::Trash)
461 .shape(IconButtonShape::Square)
462 .icon_size(IconSize::XSmall)
463 .icon_color(Color::Muted)
464 .tooltip(move |_window, cx| {
465 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
466 })
467 .on_click(cx.listener(move |this, _, _, cx| {
468 this.remove_thread(ix, cx);
469 cx.stop_propagation()
470 })),
471 )
472 } else {
473 None
474 })
475 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
476 )
477 .into_any_element()
478 }
479}
480
481impl Focusable for AcpThreadHistory {
482 fn focus_handle(&self, cx: &App) -> FocusHandle {
483 self.search_editor.focus_handle(cx)
484 }
485}
486
487impl Render for AcpThreadHistory {
488 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
489 let has_no_history = self.history_store.read(cx).is_empty(cx);
490
491 v_flex()
492 .key_context("ThreadHistory")
493 .size_full()
494 .bg(cx.theme().colors().panel_background)
495 .on_action(cx.listener(Self::select_previous))
496 .on_action(cx.listener(Self::select_next))
497 .on_action(cx.listener(Self::select_first))
498 .on_action(cx.listener(Self::select_last))
499 .on_action(cx.listener(Self::confirm))
500 .on_action(cx.listener(Self::remove_selected_thread))
501 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
502 this.remove_history(window, cx);
503 }))
504 .child(
505 h_flex()
506 .h(Tab::container_height(cx))
507 .w_full()
508 .py_1()
509 .px_2()
510 .gap_2()
511 .justify_between()
512 .border_b_1()
513 .border_color(cx.theme().colors().border)
514 .child(
515 Icon::new(IconName::MagnifyingGlass)
516 .color(Color::Muted)
517 .size(IconSize::Small),
518 )
519 .child(self.search_editor.clone()),
520 )
521 .child({
522 let view = v_flex()
523 .id("list-container")
524 .relative()
525 .overflow_hidden()
526 .flex_grow();
527
528 if has_no_history {
529 view.justify_center().items_center().child(
530 Label::new("You don't have any past threads yet.")
531 .size(LabelSize::Small)
532 .color(Color::Muted),
533 )
534 } else if self.search_produced_no_matches() {
535 view.justify_center()
536 .items_center()
537 .child(Label::new("No threads match your search.").size(LabelSize::Small))
538 } else {
539 view.child(
540 uniform_list(
541 "thread-history",
542 self.visible_items.len(),
543 cx.processor(|this, range: Range<usize>, window, cx| {
544 this.render_list_items(range, window, cx)
545 }),
546 )
547 .p_1()
548 .pr_4()
549 .track_scroll(&self.scroll_handle)
550 .flex_grow(),
551 )
552 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
553 }
554 })
555 .when(!has_no_history, |this| {
556 this.child(
557 h_flex()
558 .p_2()
559 .border_t_1()
560 .border_color(cx.theme().colors().border_variant)
561 .when(!self.confirming_delete_history, |this| {
562 this.child(
563 Button::new("delete_history", "Delete All History")
564 .full_width()
565 .style(ButtonStyle::Outlined)
566 .label_size(LabelSize::Small)
567 .on_click(cx.listener(|this, _, window, cx| {
568 this.prompt_delete_history(window, cx);
569 })),
570 )
571 })
572 .when(self.confirming_delete_history, |this| {
573 this.w_full()
574 .gap_2()
575 .flex_wrap()
576 .justify_between()
577 .child(
578 h_flex()
579 .flex_wrap()
580 .gap_1()
581 .child(
582 Label::new("Delete all threads?")
583 .size(LabelSize::Small),
584 )
585 .child(
586 Label::new("You won't be able to recover them later.")
587 .size(LabelSize::Small)
588 .color(Color::Muted),
589 ),
590 )
591 .child(
592 h_flex()
593 .gap_1()
594 .child(
595 Button::new("cancel_delete", "Cancel")
596 .label_size(LabelSize::Small)
597 .on_click(cx.listener(|this, _, window, cx| {
598 this.cancel_delete_history(window, cx);
599 })),
600 )
601 .child(
602 Button::new("confirm_delete", "Delete")
603 .style(ButtonStyle::Tinted(ui::TintColor::Error))
604 .color(Color::Error)
605 .label_size(LabelSize::Small)
606 .on_click(cx.listener(|_, _, window, cx| {
607 window.dispatch_action(
608 Box::new(RemoveHistory),
609 cx,
610 );
611 })),
612 ),
613 )
614 }),
615 )
616 })
617 }
618}
619
620#[derive(IntoElement)]
621pub struct AcpHistoryEntryElement {
622 entry: HistoryEntry,
623 thread_view: WeakEntity<AcpThreadView>,
624 selected: bool,
625 hovered: bool,
626 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
627}
628
629impl AcpHistoryEntryElement {
630 pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> Self {
631 Self {
632 entry,
633 thread_view,
634 selected: false,
635 hovered: false,
636 on_hover: Box::new(|_, _, _| {}),
637 }
638 }
639
640 pub fn hovered(mut self, hovered: bool) -> Self {
641 self.hovered = hovered;
642 self
643 }
644
645 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
646 self.on_hover = Box::new(on_hover);
647 self
648 }
649}
650
651impl RenderOnce for AcpHistoryEntryElement {
652 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
653 let id = self.entry.id();
654 let title = self.entry.title();
655 let timestamp = self.entry.updated_at();
656
657 let formatted_time = {
658 let now = chrono::Utc::now();
659 let duration = now.signed_duration_since(timestamp);
660
661 if duration.num_days() > 0 {
662 format!("{}d", duration.num_days())
663 } else if duration.num_hours() > 0 {
664 format!("{}h ago", duration.num_hours())
665 } else if duration.num_minutes() > 0 {
666 format!("{}m ago", duration.num_minutes())
667 } else {
668 "Just now".to_string()
669 }
670 };
671
672 ListItem::new(id)
673 .rounded()
674 .toggle_state(self.selected)
675 .spacing(ListItemSpacing::Sparse)
676 .start_slot(
677 h_flex()
678 .w_full()
679 .gap_2()
680 .justify_between()
681 .child(Label::new(title).size(LabelSize::Small).truncate())
682 .child(
683 Label::new(formatted_time)
684 .color(Color::Muted)
685 .size(LabelSize::XSmall),
686 ),
687 )
688 .on_hover(self.on_hover)
689 .end_slot::<IconButton>(if self.hovered || self.selected {
690 Some(
691 IconButton::new("delete", IconName::Trash)
692 .shape(IconButtonShape::Square)
693 .icon_size(IconSize::XSmall)
694 .icon_color(Color::Muted)
695 .tooltip(move |_window, cx| {
696 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
697 })
698 .on_click({
699 let thread_view = self.thread_view.clone();
700 let entry = self.entry.clone();
701
702 move |_event, _window, cx| {
703 if let Some(thread_view) = thread_view.upgrade() {
704 thread_view.update(cx, |thread_view, cx| {
705 thread_view.delete_history_entry(entry.clone(), cx);
706 });
707 }
708 }
709 }),
710 )
711 } else {
712 None
713 })
714 .on_click({
715 let thread_view = self.thread_view.clone();
716 let entry = self.entry;
717
718 move |_event, window, cx| {
719 if let Some(workspace) = thread_view
720 .upgrade()
721 .and_then(|view| view.read(cx).workspace().upgrade())
722 {
723 match &entry {
724 HistoryEntry::AcpThread(thread_metadata) => {
725 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
726 panel.update(cx, |panel, cx| {
727 panel.load_agent_thread(
728 thread_metadata.clone(),
729 window,
730 cx,
731 );
732 });
733 }
734 }
735 HistoryEntry::TextThread(text_thread) => {
736 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
737 panel.update(cx, |panel, cx| {
738 panel
739 .open_saved_text_thread(
740 text_thread.path.clone(),
741 window,
742 cx,
743 )
744 .detach_and_log_err(cx);
745 });
746 }
747 }
748 }
749 }
750 }
751 })
752 }
753}
754
755#[derive(Clone, Copy)]
756pub enum EntryTimeFormat {
757 DateAndTime,
758 TimeOnly,
759}
760
761impl EntryTimeFormat {
762 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
763 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
764
765 match self {
766 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
767 timestamp,
768 OffsetDateTime::now_utc(),
769 timezone,
770 time_format::TimestampFormat::EnhancedAbsolute,
771 ),
772 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
773 }
774 }
775}
776
777impl From<TimeBucket> for EntryTimeFormat {
778 fn from(bucket: TimeBucket) -> Self {
779 match bucket {
780 TimeBucket::Today => EntryTimeFormat::TimeOnly,
781 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
782 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
783 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
784 TimeBucket::All => EntryTimeFormat::DateAndTime,
785 }
786 }
787}
788
789#[derive(PartialEq, Eq, Clone, Copy, Debug)]
790enum TimeBucket {
791 Today,
792 Yesterday,
793 ThisWeek,
794 PastWeek,
795 All,
796}
797
798impl TimeBucket {
799 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
800 if date == reference {
801 return TimeBucket::Today;
802 }
803
804 if date == reference - TimeDelta::days(1) {
805 return TimeBucket::Yesterday;
806 }
807
808 let week = date.iso_week();
809
810 if reference.iso_week() == week {
811 return TimeBucket::ThisWeek;
812 }
813
814 let last_week = (reference - TimeDelta::days(7)).iso_week();
815
816 if week == last_week {
817 return TimeBucket::PastWeek;
818 }
819
820 TimeBucket::All
821 }
822}
823
824impl Display for TimeBucket {
825 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
826 match self {
827 TimeBucket::Today => write!(f, "Today"),
828 TimeBucket::Yesterday => write!(f, "Yesterday"),
829 TimeBucket::ThisWeek => write!(f, "This Week"),
830 TimeBucket::PastWeek => write!(f, "Past Week"),
831 TimeBucket::All => write!(f, "All"),
832 }
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use chrono::NaiveDate;
840
841 #[test]
842 fn test_time_bucket_from_dates() {
843 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
844
845 let date = today;
846 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
847
848 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
849 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
850
851 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
852 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
853
854 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
855 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
856
857 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
858 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
859
860 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
861 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
862
863 // All: not in this week or last week
864 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
865 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
866
867 // Test year boundary cases
868 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
869
870 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
871 assert_eq!(
872 TimeBucket::from_dates(new_year, date),
873 TimeBucket::Yesterday
874 );
875
876 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
877 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
878 }
879}