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::{AssistantPanel, RemoveSelectedThread};
23
24pub struct ThreadHistory {
25 assistant_panel: WeakEntity<AssistantPanel>,
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 assistant_panel: WeakEntity<AssistantPanel>,
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 assistant_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.assistant_panel.update(cx, move |this, cx| {
384 this.open_thread_by_id(&thread.id, window, cx)
385 }),
386 HistoryEntry::Context(context) => {
387 self.assistant_panel.update(cx, move |this, cx| {
388 this.open_saved_prompt_editor(context.path.clone(), window, cx)
389 })
390 }
391 };
392
393 if let Some(task) = task_result.log_err() {
394 task.detach_and_log_err(cx);
395 };
396
397 cx.notify();
398 }
399 }
400
401 fn remove_selected_thread(
402 &mut self,
403 _: &RemoveSelectedThread,
404 _window: &mut Window,
405 cx: &mut Context<Self>,
406 ) {
407 if let Some(entry) = self.get_match(self.selected_index) {
408 let task_result = match entry {
409 HistoryEntry::Thread(thread) => self
410 .assistant_panel
411 .update(cx, |this, cx| this.delete_thread(&thread.id, cx)),
412 HistoryEntry::Context(context) => self
413 .assistant_panel
414 .update(cx, |this, cx| this.delete_context(context.path.clone(), cx)),
415 };
416
417 if let Some(task) = task_result.log_err() {
418 task.detach_and_log_err(cx);
419 };
420
421 cx.notify();
422 }
423 }
424
425 fn list_items(
426 &mut self,
427 range: Range<usize>,
428 _window: &mut Window,
429 cx: &mut Context<Self>,
430 ) -> Vec<AnyElement> {
431 let range_start = range.start;
432
433 match &self.search_state {
434 SearchState::Empty => self
435 .separated_items
436 .get(range)
437 .iter()
438 .flat_map(|items| {
439 items
440 .iter()
441 .map(|item| self.render_list_item(item.entry_index(), item, vec![], cx))
442 })
443 .collect(),
444 SearchState::Searched { matches, .. } => matches[range]
445 .iter()
446 .enumerate()
447 .map(|(ix, m)| {
448 self.render_list_item(
449 Some(range_start + ix),
450 &HistoryListItem::Entry {
451 index: m.candidate_id,
452 format: EntryTimeFormat::DateAndTime,
453 },
454 m.positions.clone(),
455 cx,
456 )
457 })
458 .collect(),
459 SearchState::Searching { .. } => {
460 vec![]
461 }
462 }
463 }
464
465 fn render_list_item(
466 &self,
467 list_entry_ix: Option<usize>,
468 item: &HistoryListItem,
469 highlight_positions: Vec<usize>,
470 cx: &App,
471 ) -> AnyElement {
472 match item {
473 HistoryListItem::Entry { index, format } => match self.all_entries.get(*index) {
474 Some(entry) => h_flex()
475 .w_full()
476 .pb_1()
477 .child(self.render_history_entry(
478 entry,
479 list_entry_ix == Some(self.selected_index),
480 highlight_positions,
481 *format,
482 ))
483 .into_any(),
484 None => Empty.into_any_element(),
485 },
486 HistoryListItem::BucketSeparator(bucket) => div()
487 .px(DynamicSpacing::Base06.rems(cx))
488 .pt_2()
489 .pb_1()
490 .child(
491 Label::new(bucket.to_string())
492 .size(LabelSize::XSmall)
493 .color(Color::Muted),
494 )
495 .into_any_element(),
496 }
497 }
498
499 fn render_history_entry(
500 &self,
501 entry: &HistoryEntry,
502 is_active: bool,
503 highlight_positions: Vec<usize>,
504 format: EntryTimeFormat,
505 ) -> AnyElement {
506 match entry {
507 HistoryEntry::Thread(thread) => PastThread::new(
508 thread.clone(),
509 self.assistant_panel.clone(),
510 is_active,
511 highlight_positions,
512 format,
513 )
514 .into_any_element(),
515 HistoryEntry::Context(context) => PastContext::new(
516 context.clone(),
517 self.assistant_panel.clone(),
518 is_active,
519 highlight_positions,
520 format,
521 )
522 .into_any_element(),
523 }
524 }
525}
526
527impl Focusable for ThreadHistory {
528 fn focus_handle(&self, cx: &App) -> FocusHandle {
529 self.search_editor.focus_handle(cx)
530 }
531}
532
533impl Render for ThreadHistory {
534 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
535 v_flex()
536 .key_context("ThreadHistory")
537 .size_full()
538 .on_action(cx.listener(Self::select_previous))
539 .on_action(cx.listener(Self::select_next))
540 .on_action(cx.listener(Self::select_first))
541 .on_action(cx.listener(Self::select_last))
542 .on_action(cx.listener(Self::confirm))
543 .on_action(cx.listener(Self::remove_selected_thread))
544 .when(!self.all_entries.is_empty(), |parent| {
545 parent.child(
546 h_flex()
547 .h(px(41.)) // Match the toolbar perfectly
548 .w_full()
549 .py_1()
550 .px_2()
551 .gap_2()
552 .justify_between()
553 .border_b_1()
554 .border_color(cx.theme().colors().border)
555 .child(
556 Icon::new(IconName::MagnifyingGlass)
557 .color(Color::Muted)
558 .size(IconSize::Small),
559 )
560 .child(self.search_editor.clone()),
561 )
562 })
563 .child({
564 let view = v_flex()
565 .id("list-container")
566 .relative()
567 .overflow_hidden()
568 .flex_grow();
569
570 if self.all_entries.is_empty() {
571 view.justify_center()
572 .child(
573 h_flex().w_full().justify_center().child(
574 Label::new("You don't have any past threads yet.")
575 .size(LabelSize::Small),
576 ),
577 )
578 } else if self.search_produced_no_matches() {
579 view.justify_center().child(
580 h_flex().w_full().justify_center().child(
581 Label::new("No threads match your search.").size(LabelSize::Small),
582 ),
583 )
584 } else {
585 view.pr_5()
586 .child(
587 uniform_list(
588 cx.entity().clone(),
589 "thread-history",
590 self.list_item_count(),
591 Self::list_items,
592 )
593 .p_1()
594 .track_scroll(self.scroll_handle.clone())
595 .flex_grow(),
596 )
597 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
598 div.child(scrollbar)
599 })
600 }
601 })
602 }
603}
604
605#[derive(IntoElement)]
606pub struct PastThread {
607 thread: SerializedThreadMetadata,
608 assistant_panel: WeakEntity<AssistantPanel>,
609 selected: bool,
610 highlight_positions: Vec<usize>,
611 timestamp_format: EntryTimeFormat,
612}
613
614impl PastThread {
615 pub fn new(
616 thread: SerializedThreadMetadata,
617 assistant_panel: WeakEntity<AssistantPanel>,
618 selected: bool,
619 highlight_positions: Vec<usize>,
620 timestamp_format: EntryTimeFormat,
621 ) -> Self {
622 Self {
623 thread,
624 assistant_panel,
625 selected,
626 highlight_positions,
627 timestamp_format,
628 }
629 }
630}
631
632impl RenderOnce for PastThread {
633 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
634 let summary = self.thread.summary;
635
636 let thread_timestamp = self.timestamp_format.format_timestamp(
637 &self.assistant_panel,
638 self.thread.updated_at.timestamp(),
639 cx,
640 );
641
642 ListItem::new(SharedString::from(self.thread.id.to_string()))
643 .rounded()
644 .toggle_state(self.selected)
645 .spacing(ListItemSpacing::Sparse)
646 .start_slot(
647 div().max_w_4_5().child(
648 HighlightedLabel::new(summary, self.highlight_positions)
649 .size(LabelSize::Small)
650 .truncate(),
651 ),
652 )
653 .end_slot(
654 h_flex()
655 .gap_1p5()
656 .child(
657 Label::new(thread_timestamp)
658 .color(Color::Muted)
659 .size(LabelSize::XSmall),
660 )
661 .child(
662 IconButton::new("delete", IconName::TrashAlt)
663 .shape(IconButtonShape::Square)
664 .icon_size(IconSize::XSmall)
665 .icon_color(Color::Muted)
666 .tooltip(move |window, cx| {
667 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
668 })
669 .on_click({
670 let assistant_panel = self.assistant_panel.clone();
671 let id = self.thread.id.clone();
672 move |_event, _window, cx| {
673 assistant_panel
674 .update(cx, |this, cx| {
675 this.delete_thread(&id, cx).detach_and_log_err(cx);
676 })
677 .ok();
678 }
679 }),
680 ),
681 )
682 .on_click({
683 let assistant_panel = self.assistant_panel.clone();
684 let id = self.thread.id.clone();
685 move |_event, window, cx| {
686 assistant_panel
687 .update(cx, |this, cx| {
688 this.open_thread_by_id(&id, window, cx)
689 .detach_and_log_err(cx);
690 })
691 .ok();
692 }
693 })
694 }
695}
696
697#[derive(IntoElement)]
698pub struct PastContext {
699 context: SavedContextMetadata,
700 assistant_panel: WeakEntity<AssistantPanel>,
701 selected: bool,
702 highlight_positions: Vec<usize>,
703 timestamp_format: EntryTimeFormat,
704}
705
706impl PastContext {
707 pub fn new(
708 context: SavedContextMetadata,
709 assistant_panel: WeakEntity<AssistantPanel>,
710 selected: bool,
711 highlight_positions: Vec<usize>,
712 timestamp_format: EntryTimeFormat,
713 ) -> Self {
714 Self {
715 context,
716 assistant_panel,
717 selected,
718 highlight_positions,
719 timestamp_format,
720 }
721 }
722}
723
724impl RenderOnce for PastContext {
725 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
726 let summary = self.context.title;
727 let context_timestamp = self.timestamp_format.format_timestamp(
728 &self.assistant_panel,
729 self.context.mtime.timestamp(),
730 cx,
731 );
732
733 ListItem::new(SharedString::from(
734 self.context.path.to_string_lossy().to_string(),
735 ))
736 .rounded()
737 .toggle_state(self.selected)
738 .spacing(ListItemSpacing::Sparse)
739 .start_slot(
740 div().max_w_4_5().child(
741 HighlightedLabel::new(summary, self.highlight_positions)
742 .size(LabelSize::Small)
743 .truncate(),
744 ),
745 )
746 .end_slot(
747 h_flex()
748 .gap_1p5()
749 .child(
750 Label::new(context_timestamp)
751 .color(Color::Muted)
752 .size(LabelSize::XSmall),
753 )
754 .child(
755 IconButton::new("delete", IconName::TrashAlt)
756 .shape(IconButtonShape::Square)
757 .icon_size(IconSize::XSmall)
758 .icon_color(Color::Muted)
759 .tooltip(move |window, cx| {
760 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
761 })
762 .on_click({
763 let assistant_panel = self.assistant_panel.clone();
764 let path = self.context.path.clone();
765 move |_event, _window, cx| {
766 assistant_panel
767 .update(cx, |this, cx| {
768 this.delete_context(path.clone(), cx)
769 .detach_and_log_err(cx);
770 })
771 .ok();
772 }
773 }),
774 ),
775 )
776 .on_click({
777 let assistant_panel = self.assistant_panel.clone();
778 let path = self.context.path.clone();
779 move |_event, window, cx| {
780 assistant_panel
781 .update(cx, |this, cx| {
782 this.open_saved_prompt_editor(path.clone(), window, cx)
783 .detach_and_log_err(cx);
784 })
785 .ok();
786 }
787 })
788 }
789}
790
791#[derive(Clone, Copy)]
792pub enum EntryTimeFormat {
793 DateAndTime,
794 TimeOnly,
795}
796
797impl EntryTimeFormat {
798 fn format_timestamp(
799 &self,
800 assistant_panel: &WeakEntity<AssistantPanel>,
801 timestamp: i64,
802 cx: &App,
803 ) -> String {
804 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
805 let timezone = assistant_panel
806 .read_with(cx, |this, _cx| this.local_timezone())
807 .unwrap_or(UtcOffset::UTC);
808
809 match &self {
810 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
811 timestamp,
812 OffsetDateTime::now_utc(),
813 timezone,
814 time_format::TimestampFormat::EnhancedAbsolute,
815 ),
816 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
817 }
818 }
819}
820
821impl From<TimeBucket> for EntryTimeFormat {
822 fn from(bucket: TimeBucket) -> Self {
823 match bucket {
824 TimeBucket::Today => EntryTimeFormat::TimeOnly,
825 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
826 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
827 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
828 TimeBucket::All => EntryTimeFormat::DateAndTime,
829 }
830 }
831}
832
833#[derive(PartialEq, Eq, Clone, Copy, Debug)]
834enum TimeBucket {
835 Today,
836 Yesterday,
837 ThisWeek,
838 PastWeek,
839 All,
840}
841
842impl TimeBucket {
843 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
844 if date == reference {
845 return TimeBucket::Today;
846 }
847
848 if date == reference - TimeDelta::days(1) {
849 return TimeBucket::Yesterday;
850 }
851
852 let week = date.iso_week();
853
854 if reference.iso_week() == week {
855 return TimeBucket::ThisWeek;
856 }
857
858 let last_week = (reference - TimeDelta::days(7)).iso_week();
859
860 if week == last_week {
861 return TimeBucket::PastWeek;
862 }
863
864 TimeBucket::All
865 }
866}
867
868impl Display for TimeBucket {
869 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
870 match self {
871 TimeBucket::Today => write!(f, "Today"),
872 TimeBucket::Yesterday => write!(f, "Yesterday"),
873 TimeBucket::ThisWeek => write!(f, "This Week"),
874 TimeBucket::PastWeek => write!(f, "Past Week"),
875 TimeBucket::All => write!(f, "All"),
876 }
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use chrono::NaiveDate;
884
885 #[test]
886 fn test_time_bucket_from_dates() {
887 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
888
889 let date = today;
890 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
891
892 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
893 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
894
895 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
896 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
897
898 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
899 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
900
901 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
902 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
903
904 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
905 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
906
907 // All: not in this week or last week
908 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
909 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
910
911 // Test year boundary cases
912 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
913
914 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
915 assert_eq!(
916 TimeBucket::from_dates(new_year, date),
917 TimeBucket::Yesterday
918 );
919
920 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
921 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
922 }
923}