1use std::fmt::Display;
2use std::ops::Range;
3use std::sync::Arc;
4
5use assistant_context_editor::SavedContextMetadata;
6use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
7use editor::{Editor, EditorEvent};
8use fuzzy::{StringMatch, StringMatchCandidate};
9use gpui::{
10 App, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
11 UniformListScrollHandle, WeakEntity, Window, uniform_list,
12};
13use time::{OffsetDateTime, UtcOffset};
14use ui::{
15 HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
16 Tooltip, prelude::*,
17};
18use util::ResultExt;
19
20use crate::history_store::{HistoryEntry, HistoryStore};
21use crate::thread_store::SerializedThreadMetadata;
22use crate::{AgentPanel, RemoveSelectedThread};
23
24pub struct ThreadHistory {
25 agent_panel: WeakEntity<AgentPanel>,
26 history_store: Entity<HistoryStore>,
27 scroll_handle: UniformListScrollHandle,
28 selected_index: usize,
29 search_editor: Entity<Editor>,
30 all_entries: Arc<Vec<HistoryEntry>>,
31 // When the search is empty, we display date separators between history entries
32 // This vector contains an enum of either a separator or an actual entry
33 separated_items: Vec<HistoryListItem>,
34 // Maps entry indexes to list item indexes
35 separated_item_indexes: Vec<u32>,
36 _separated_items_task: Option<Task<()>>,
37 search_state: SearchState,
38 scrollbar_visibility: bool,
39 scrollbar_state: ScrollbarState,
40 _subscriptions: Vec<gpui::Subscription>,
41}
42
43enum SearchState {
44 Empty,
45 Searching {
46 query: SharedString,
47 _task: Task<()>,
48 },
49 Searched {
50 query: SharedString,
51 matches: Vec<StringMatch>,
52 },
53}
54
55enum HistoryListItem {
56 BucketSeparator(TimeBucket),
57 Entry {
58 index: usize,
59 format: EntryTimeFormat,
60 },
61}
62
63impl HistoryListItem {
64 fn entry_index(&self) -> Option<usize> {
65 match self {
66 HistoryListItem::BucketSeparator(_) => None,
67 HistoryListItem::Entry { index, .. } => Some(*index),
68 }
69 }
70}
71
72impl ThreadHistory {
73 pub(crate) fn new(
74 agent_panel: WeakEntity<AgentPanel>,
75 history_store: Entity<HistoryStore>,
76 window: &mut Window,
77 cx: &mut Context<Self>,
78 ) -> Self {
79 let search_editor = cx.new(|cx| {
80 let mut editor = Editor::single_line(window, cx);
81 editor.set_placeholder_text("Search threads...", cx);
82 editor
83 });
84
85 let search_editor_subscription =
86 cx.subscribe(&search_editor, |this, search_editor, event, cx| {
87 if let EditorEvent::BufferEdited = event {
88 let query = search_editor.read(cx).text(cx);
89 this.search(query.into(), cx);
90 }
91 });
92
93 let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
94 this.update_all_entries(cx);
95 });
96
97 let scroll_handle = UniformListScrollHandle::default();
98 let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
99
100 let mut this = Self {
101 agent_panel,
102 history_store,
103 scroll_handle,
104 selected_index: 0,
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(HistoryListItem::BucketSeparator(entry_bucket));
164 }
165
166 indexes.push(items.len() as u32);
167 items.push(HistoryListItem::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 &HistoryListItem::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: &HistoryListItem,
489 highlight_positions: Vec<usize>,
490 cx: &App,
491 ) -> AnyElement {
492 match item {
493 HistoryListItem::Entry { index, format } => match self.all_entries.get(*index) {
494 Some(entry) => h_flex()
495 .w_full()
496 .pb_1()
497 .child(self.render_history_entry(
498 entry,
499 list_entry_ix == Some(self.selected_index),
500 highlight_positions,
501 *format,
502 ))
503 .into_any(),
504 None => Empty.into_any_element(),
505 },
506 HistoryListItem::BucketSeparator(bucket) => div()
507 .px(DynamicSpacing::Base06.rems(cx))
508 .pt_2()
509 .pb_1()
510 .child(
511 Label::new(bucket.to_string())
512 .size(LabelSize::XSmall)
513 .color(Color::Muted),
514 )
515 .into_any_element(),
516 }
517 }
518
519 fn render_history_entry(
520 &self,
521 entry: &HistoryEntry,
522 is_active: bool,
523 highlight_positions: Vec<usize>,
524 format: EntryTimeFormat,
525 ) -> AnyElement {
526 match entry {
527 HistoryEntry::Thread(thread) => PastThread::new(
528 thread.clone(),
529 self.agent_panel.clone(),
530 is_active,
531 highlight_positions,
532 format,
533 )
534 .into_any_element(),
535 HistoryEntry::Context(context) => PastContext::new(
536 context.clone(),
537 self.agent_panel.clone(),
538 is_active,
539 highlight_positions,
540 format,
541 )
542 .into_any_element(),
543 }
544 }
545}
546
547impl Focusable for ThreadHistory {
548 fn focus_handle(&self, cx: &App) -> FocusHandle {
549 self.search_editor.focus_handle(cx)
550 }
551}
552
553impl Render for ThreadHistory {
554 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
555 v_flex()
556 .key_context("ThreadHistory")
557 .size_full()
558 .on_action(cx.listener(Self::select_previous))
559 .on_action(cx.listener(Self::select_next))
560 .on_action(cx.listener(Self::select_first))
561 .on_action(cx.listener(Self::select_last))
562 .on_action(cx.listener(Self::confirm))
563 .on_action(cx.listener(Self::remove_selected_thread))
564 .when(!self.all_entries.is_empty(), |parent| {
565 parent.child(
566 h_flex()
567 .h(px(41.)) // Match the toolbar perfectly
568 .w_full()
569 .py_1()
570 .px_2()
571 .gap_2()
572 .justify_between()
573 .border_b_1()
574 .border_color(cx.theme().colors().border)
575 .child(
576 Icon::new(IconName::MagnifyingGlass)
577 .color(Color::Muted)
578 .size(IconSize::Small),
579 )
580 .child(self.search_editor.clone()),
581 )
582 })
583 .child({
584 let view = v_flex()
585 .id("list-container")
586 .relative()
587 .overflow_hidden()
588 .flex_grow();
589
590 if self.all_entries.is_empty() {
591 view.justify_center()
592 .child(
593 h_flex().w_full().justify_center().child(
594 Label::new("You don't have any past threads yet.")
595 .size(LabelSize::Small),
596 ),
597 )
598 } else if self.search_produced_no_matches() {
599 view.justify_center().child(
600 h_flex().w_full().justify_center().child(
601 Label::new("No threads match your search.").size(LabelSize::Small),
602 ),
603 )
604 } else {
605 view.pr_5()
606 .child(
607 uniform_list(
608 cx.entity().clone(),
609 "thread-history",
610 self.list_item_count(),
611 Self::list_items,
612 )
613 .p_1()
614 .track_scroll(self.scroll_handle.clone())
615 .flex_grow(),
616 )
617 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
618 div.child(scrollbar)
619 })
620 }
621 })
622 }
623}
624
625#[derive(IntoElement)]
626pub struct PastThread {
627 thread: SerializedThreadMetadata,
628 agent_panel: WeakEntity<AgentPanel>,
629 selected: bool,
630 highlight_positions: Vec<usize>,
631 timestamp_format: EntryTimeFormat,
632}
633
634impl PastThread {
635 pub fn new(
636 thread: SerializedThreadMetadata,
637 agent_panel: WeakEntity<AgentPanel>,
638 selected: bool,
639 highlight_positions: Vec<usize>,
640 timestamp_format: EntryTimeFormat,
641 ) -> Self {
642 Self {
643 thread,
644 agent_panel,
645 selected,
646 highlight_positions,
647 timestamp_format,
648 }
649 }
650}
651
652impl RenderOnce for PastThread {
653 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
654 let summary = self.thread.summary;
655
656 let thread_timestamp = self.timestamp_format.format_timestamp(
657 &self.agent_panel,
658 self.thread.updated_at.timestamp(),
659 cx,
660 );
661
662 ListItem::new(SharedString::from(self.thread.id.to_string()))
663 .rounded()
664 .toggle_state(self.selected)
665 .spacing(ListItemSpacing::Sparse)
666 .start_slot(
667 div().max_w_4_5().child(
668 HighlightedLabel::new(summary, self.highlight_positions)
669 .size(LabelSize::Small)
670 .truncate(),
671 ),
672 )
673 .end_slot(
674 h_flex()
675 .gap_1p5()
676 .child(
677 Label::new(thread_timestamp)
678 .color(Color::Muted)
679 .size(LabelSize::XSmall),
680 )
681 .child(
682 IconButton::new("delete", IconName::TrashAlt)
683 .shape(IconButtonShape::Square)
684 .icon_size(IconSize::XSmall)
685 .icon_color(Color::Muted)
686 .tooltip(move |window, cx| {
687 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
688 })
689 .on_click({
690 let agent_panel = self.agent_panel.clone();
691 let id = self.thread.id.clone();
692 move |_event, _window, cx| {
693 agent_panel
694 .update(cx, |this, cx| {
695 this.delete_thread(&id, cx).detach_and_log_err(cx);
696 })
697 .ok();
698 }
699 }),
700 ),
701 )
702 .on_click({
703 let agent_panel = self.agent_panel.clone();
704 let id = self.thread.id.clone();
705 move |_event, window, cx| {
706 agent_panel
707 .update(cx, |this, cx| {
708 this.open_thread_by_id(&id, window, cx)
709 .detach_and_log_err(cx);
710 })
711 .ok();
712 }
713 })
714 }
715}
716
717#[derive(IntoElement)]
718pub struct PastContext {
719 context: SavedContextMetadata,
720 agent_panel: WeakEntity<AgentPanel>,
721 selected: bool,
722 highlight_positions: Vec<usize>,
723 timestamp_format: EntryTimeFormat,
724}
725
726impl PastContext {
727 pub fn new(
728 context: SavedContextMetadata,
729 agent_panel: WeakEntity<AgentPanel>,
730 selected: bool,
731 highlight_positions: Vec<usize>,
732 timestamp_format: EntryTimeFormat,
733 ) -> Self {
734 Self {
735 context,
736 agent_panel,
737 selected,
738 highlight_positions,
739 timestamp_format,
740 }
741 }
742}
743
744impl RenderOnce for PastContext {
745 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
746 let summary = self.context.title;
747 let context_timestamp = self.timestamp_format.format_timestamp(
748 &self.agent_panel,
749 self.context.mtime.timestamp(),
750 cx,
751 );
752
753 ListItem::new(SharedString::from(
754 self.context.path.to_string_lossy().to_string(),
755 ))
756 .rounded()
757 .toggle_state(self.selected)
758 .spacing(ListItemSpacing::Sparse)
759 .start_slot(
760 div().max_w_4_5().child(
761 HighlightedLabel::new(summary, self.highlight_positions)
762 .size(LabelSize::Small)
763 .truncate(),
764 ),
765 )
766 .end_slot(
767 h_flex()
768 .gap_1p5()
769 .child(
770 Label::new(context_timestamp)
771 .color(Color::Muted)
772 .size(LabelSize::XSmall),
773 )
774 .child(
775 IconButton::new("delete", IconName::TrashAlt)
776 .shape(IconButtonShape::Square)
777 .icon_size(IconSize::XSmall)
778 .icon_color(Color::Muted)
779 .tooltip(move |window, cx| {
780 Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
781 })
782 .on_click({
783 let agent_panel = self.agent_panel.clone();
784 let path = self.context.path.clone();
785 move |_event, _window, cx| {
786 agent_panel
787 .update(cx, |this, cx| {
788 this.delete_context(path.clone(), cx)
789 .detach_and_log_err(cx);
790 })
791 .ok();
792 }
793 }),
794 ),
795 )
796 .on_click({
797 let agent_panel = self.agent_panel.clone();
798 let path = self.context.path.clone();
799 move |_event, window, cx| {
800 agent_panel
801 .update(cx, |this, cx| {
802 this.open_saved_prompt_editor(path.clone(), window, cx)
803 .detach_and_log_err(cx);
804 })
805 .ok();
806 }
807 })
808 }
809}
810
811#[derive(Clone, Copy)]
812pub enum EntryTimeFormat {
813 DateAndTime,
814 TimeOnly,
815}
816
817impl EntryTimeFormat {
818 fn format_timestamp(
819 &self,
820 agent_panel: &WeakEntity<AgentPanel>,
821 timestamp: i64,
822 cx: &App,
823 ) -> String {
824 let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
825 let timezone = agent_panel
826 .read_with(cx, |this, _cx| this.local_timezone())
827 .unwrap_or(UtcOffset::UTC);
828
829 match &self {
830 EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
831 timestamp,
832 OffsetDateTime::now_utc(),
833 timezone,
834 time_format::TimestampFormat::EnhancedAbsolute,
835 ),
836 EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
837 }
838 }
839}
840
841impl From<TimeBucket> for EntryTimeFormat {
842 fn from(bucket: TimeBucket) -> Self {
843 match bucket {
844 TimeBucket::Today => EntryTimeFormat::TimeOnly,
845 TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
846 TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
847 TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
848 TimeBucket::All => EntryTimeFormat::DateAndTime,
849 }
850 }
851}
852
853#[derive(PartialEq, Eq, Clone, Copy, Debug)]
854enum TimeBucket {
855 Today,
856 Yesterday,
857 ThisWeek,
858 PastWeek,
859 All,
860}
861
862impl TimeBucket {
863 fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
864 if date == reference {
865 return TimeBucket::Today;
866 }
867
868 if date == reference - TimeDelta::days(1) {
869 return TimeBucket::Yesterday;
870 }
871
872 let week = date.iso_week();
873
874 if reference.iso_week() == week {
875 return TimeBucket::ThisWeek;
876 }
877
878 let last_week = (reference - TimeDelta::days(7)).iso_week();
879
880 if week == last_week {
881 return TimeBucket::PastWeek;
882 }
883
884 TimeBucket::All
885 }
886}
887
888impl Display for TimeBucket {
889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890 match self {
891 TimeBucket::Today => write!(f, "Today"),
892 TimeBucket::Yesterday => write!(f, "Yesterday"),
893 TimeBucket::ThisWeek => write!(f, "This Week"),
894 TimeBucket::PastWeek => write!(f, "Past Week"),
895 TimeBucket::All => write!(f, "All"),
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use chrono::NaiveDate;
904
905 #[test]
906 fn test_time_bucket_from_dates() {
907 let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
908
909 let date = today;
910 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
911
912 let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
913 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
914
915 let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
916 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
917
918 let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
919 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
920
921 let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
922 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
923
924 let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
925 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
926
927 // All: not in this week or last week
928 let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
929 assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
930
931 // Test year boundary cases
932 let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
933
934 let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
935 assert_eq!(
936 TimeBucket::from_dates(new_year, date),
937 TimeBucket::Yesterday
938 );
939
940 let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
941 assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
942 }
943}