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