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