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