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