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