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