thread_history.rs

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