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