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