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