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