1use crate::acp::AcpThreadView;
2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
3use agent::{HistoryEntry, HistoryStore};
4use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
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 let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone);
406
407 h_flex()
408 .w_full()
409 .pb_1()
410 .child(
411 ListItem::new(ix)
412 .rounded()
413 .toggle_state(selected)
414 .spacing(ListItemSpacing::Sparse)
415 .start_slot(
416 h_flex()
417 .w_full()
418 .gap_2()
419 .justify_between()
420 .child(
421 HighlightedLabel::new(entry.title(), highlight_positions)
422 .size(LabelSize::Small)
423 .truncate(),
424 )
425 .child(
426 Label::new(thread_timestamp)
427 .color(Color::Muted)
428 .size(LabelSize::XSmall),
429 ),
430 )
431 .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
432 if *is_hovered {
433 this.hovered_index = Some(ix);
434 } else if this.hovered_index == Some(ix) {
435 this.hovered_index = None;
436 }
437
438 cx.notify();
439 }))
440 .end_slot::<IconButton>(if hovered {
441 Some(
442 IconButton::new("delete", IconName::Trash)
443 .shape(IconButtonShape::Square)
444 .icon_size(IconSize::XSmall)
445 .icon_color(Color::Muted)
446 .tooltip(move |_window, cx| {
447 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
448 })
449 .on_click(
450 cx.listener(move |this, _, _, cx| this.remove_thread(ix, cx)),
451 ),
452 )
453 } else {
454 None
455 })
456 .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
457 )
458 .into_any_element()
459 }
460}
461
462impl Focusable for AcpThreadHistory {
463 fn focus_handle(&self, cx: &App) -> FocusHandle {
464 self.search_editor.focus_handle(cx)
465 }
466}
467
468impl Render for AcpThreadHistory {
469 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
470 let has_no_history = self.history_store.read(cx).is_empty(cx);
471
472 v_flex()
473 .key_context("ThreadHistory")
474 .size_full()
475 .bg(cx.theme().colors().panel_background)
476 .on_action(cx.listener(Self::select_previous))
477 .on_action(cx.listener(Self::select_next))
478 .on_action(cx.listener(Self::select_first))
479 .on_action(cx.listener(Self::select_last))
480 .on_action(cx.listener(Self::confirm))
481 .on_action(cx.listener(Self::remove_selected_thread))
482 .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
483 this.remove_history(window, cx);
484 }))
485 .child(
486 h_flex()
487 .h(Tab::container_height(cx))
488 .w_full()
489 .py_1()
490 .px_2()
491 .gap_2()
492 .justify_between()
493 .border_b_1()
494 .border_color(cx.theme().colors().border)
495 .child(
496 Icon::new(IconName::MagnifyingGlass)
497 .color(Color::Muted)
498 .size(IconSize::Small),
499 )
500 .child(self.search_editor.clone()),
501 )
502 .child({
503 let view = v_flex()
504 .id("list-container")
505 .relative()
506 .overflow_hidden()
507 .flex_grow();
508
509 if has_no_history {
510 view.justify_center().items_center().child(
511 Label::new("You don't have any past threads yet.")
512 .size(LabelSize::Small)
513 .color(Color::Muted),
514 )
515 } else if self.search_produced_no_matches() {
516 view.justify_center()
517 .items_center()
518 .child(Label::new("No threads match your search.").size(LabelSize::Small))
519 } else {
520 view.child(
521 uniform_list(
522 "thread-history",
523 self.visible_items.len(),
524 cx.processor(|this, range: Range<usize>, window, cx| {
525 this.render_list_items(range, window, cx)
526 }),
527 )
528 .p_1()
529 .pr_4()
530 .track_scroll(&self.scroll_handle)
531 .flex_grow(),
532 )
533 .vertical_scrollbar_for(&self.scroll_handle, window, cx)
534 }
535 })
536 .when(!has_no_history, |this| {
537 this.child(
538 h_flex()
539 .p_2()
540 .border_t_1()
541 .border_color(cx.theme().colors().border_variant)
542 .when(!self.confirming_delete_history, |this| {
543 this.child(
544 Button::new("delete_history", "Delete All History")
545 .full_width()
546 .style(ButtonStyle::Outlined)
547 .label_size(LabelSize::Small)
548 .on_click(cx.listener(|this, _, window, cx| {
549 this.prompt_delete_history(window, cx);
550 })),
551 )
552 })
553 .when(self.confirming_delete_history, |this| {
554 this.w_full()
555 .gap_2()
556 .flex_wrap()
557 .justify_between()
558 .child(
559 h_flex()
560 .flex_wrap()
561 .gap_1()
562 .child(
563 Label::new("Delete all threads?")
564 .size(LabelSize::Small),
565 )
566 .child(
567 Label::new("You won't be able to recover them later.")
568 .size(LabelSize::Small)
569 .color(Color::Muted),
570 ),
571 )
572 .child(
573 h_flex()
574 .gap_1()
575 .child(
576 Button::new("cancel_delete", "Cancel")
577 .label_size(LabelSize::Small)
578 .on_click(cx.listener(|this, _, window, cx| {
579 this.cancel_delete_history(window, cx);
580 })),
581 )
582 .child(
583 Button::new("confirm_delete", "Delete")
584 .style(ButtonStyle::Tinted(ui::TintColor::Error))
585 .color(Color::Error)
586 .label_size(LabelSize::Small)
587 .on_click(cx.listener(|_, _, window, cx| {
588 window.dispatch_action(
589 Box::new(RemoveHistory),
590 cx,
591 );
592 })),
593 ),
594 )
595 }),
596 )
597 })
598 }
599}
600
601#[derive(IntoElement)]
602pub struct AcpHistoryEntryElement {
603 entry: HistoryEntry,
604 thread_view: WeakEntity<AcpThreadView>,
605 selected: bool,
606 hovered: bool,
607 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
608}
609
610impl AcpHistoryEntryElement {
611 pub fn new(entry: HistoryEntry, thread_view: WeakEntity<AcpThreadView>) -> Self {
612 Self {
613 entry,
614 thread_view,
615 selected: false,
616 hovered: false,
617 on_hover: Box::new(|_, _, _| {}),
618 }
619 }
620
621 pub fn hovered(mut self, hovered: bool) -> Self {
622 self.hovered = hovered;
623 self
624 }
625
626 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
627 self.on_hover = Box::new(on_hover);
628 self
629 }
630}
631
632impl RenderOnce for AcpHistoryEntryElement {
633 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
634 let id = self.entry.id();
635 let title = self.entry.title();
636 let timestamp = self.entry.updated_at();
637
638 let formatted_time = {
639 let now = chrono::Utc::now();
640 let duration = now.signed_duration_since(timestamp);
641
642 if duration.num_days() > 0 {
643 format!("{}d", duration.num_days())
644 } else if duration.num_hours() > 0 {
645 format!("{}h ago", duration.num_hours())
646 } else if duration.num_minutes() > 0 {
647 format!("{}m ago", duration.num_minutes())
648 } else {
649 "Just now".to_string()
650 }
651 };
652
653 ListItem::new(id)
654 .rounded()
655 .toggle_state(self.selected)
656 .spacing(ListItemSpacing::Sparse)
657 .start_slot(
658 h_flex()
659 .w_full()
660 .gap_2()
661 .justify_between()
662 .child(Label::new(title).size(LabelSize::Small).truncate())
663 .child(
664 Label::new(formatted_time)
665 .color(Color::Muted)
666 .size(LabelSize::XSmall),
667 ),
668 )
669 .on_hover(self.on_hover)
670 .end_slot::<IconButton>(if self.hovered || self.selected {
671 Some(
672 IconButton::new("delete", IconName::Trash)
673 .shape(IconButtonShape::Square)
674 .icon_size(IconSize::XSmall)
675 .icon_color(Color::Muted)
676 .tooltip(move |_window, cx| {
677 Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
678 })
679 .on_click({
680 let thread_view = self.thread_view.clone();
681 let entry = self.entry.clone();
682
683 move |_event, _window, cx| {
684 if let Some(thread_view) = thread_view.upgrade() {
685 thread_view.update(cx, |thread_view, cx| {
686 thread_view.delete_history_entry(entry.clone(), cx);
687 });
688 }
689 }
690 }),
691 )
692 } else {
693 None
694 })
695 .on_click({
696 let thread_view = self.thread_view.clone();
697 let entry = self.entry;
698
699 move |_event, window, cx| {
700 if let Some(workspace) = thread_view
701 .upgrade()
702 .and_then(|view| view.read(cx).workspace().upgrade())
703 {
704 match &entry {
705 HistoryEntry::AcpThread(thread_metadata) => {
706 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
707 panel.update(cx, |panel, cx| {
708 panel.load_agent_thread(
709 thread_metadata.clone(),
710 window,
711 cx,
712 );
713 });
714 }
715 }
716 HistoryEntry::TextThread(text_thread) => {
717 if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
718 panel.update(cx, |panel, cx| {
719 panel
720 .open_saved_text_thread(
721 text_thread.path.clone(),
722 window,
723 cx,
724 )
725 .detach_and_log_err(cx);
726 });
727 }
728 }
729 }
730 }
731 }
732 })
733 }
734}
735
736#[derive(Clone, Copy)]
737pub enum EntryTimeFormat {
738 DateAndTime,
739 TimeOnly,
740}
741
742impl EntryTimeFormat {
743 fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
744 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
745
746 match self {
747 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
748 timestamp,
749 OffsetDateTime::now_utc(),
750 timezone,
751 time_format::TimestampFormat::EnhancedAbsolute,
752 ),
753 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
754 }
755 }
756}
757
758impl From<TimeBucket> for EntryTimeFormat {
759 fn from(bucket: TimeBucket) -> Self {
760 match bucket {
761 TimeBucket::Today => EntryTimeFormat::TimeOnly,
762 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
763 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
764 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
765 TimeBucket::All => EntryTimeFormat::DateAndTime,
766 }
767 }
768}
769
770#[derive(PartialEq, Eq, Clone, Copy, Debug)]
771enum TimeBucket {
772 Today,
773 Yesterday,
774 ThisWeek,
775 PastWeek,
776 All,
777}
778
779impl TimeBucket {
780 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
781 if date == reference {
782 return TimeBucket::Today;
783 }
784
785 if date == reference - TimeDelta::days(1) {
786 return TimeBucket::Yesterday;
787 }
788
789 let week = date.iso_week();
790
791 if reference.iso_week() == week {
792 return TimeBucket::ThisWeek;
793 }
794
795 let last_week = (reference - TimeDelta::days(7)).iso_week();
796
797 if week == last_week {
798 return TimeBucket::PastWeek;
799 }
800
801 TimeBucket::All
802 }
803}
804
805impl Display for TimeBucket {
806 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
807 match self {
808 TimeBucket::Today => write!(f, "Today"),
809 TimeBucket::Yesterday => write!(f, "Yesterday"),
810 TimeBucket::ThisWeek => write!(f, "This Week"),
811 TimeBucket::PastWeek => write!(f, "Past Week"),
812 TimeBucket::All => write!(f, "All"),
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use chrono::NaiveDate;
821
822 #[test]
823 fn test_time_bucket_from_dates() {
824 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
825
826 let date = today;
827 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
828
829 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
830 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
831
832 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
833 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
834
835 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
836 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
837
838 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
839 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
840
841 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
842 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
843
844 // All: not in this week or last week
845 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
846 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
847
848 // Test year boundary cases
849 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
850
851 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
852 assert_eq!(
853 TimeBucket::from_dates(new_year, date),
854 TimeBucket::Yesterday
855 );
856
857 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
858 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
859 }
860}