thread_history.rs

  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        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(ListItemType::BucketSeparator(entry_bucket));
164                }
165
166                indexes.push(items.len() as u32);
167                items.push(ListItemType::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                        &ListItemType::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: &ListItemType,
489        highlight_positions: Vec<usize>,
490        cx: &Context<Self>,
491    ) -> AnyElement {
492        match item {
493            ListItemType::Entry { index, format } => match self.all_entries.get(*index) {
494                Some(entry) => h_flex()
495                    .w_full()
496                    .pb_1()
497                    .child(
498                        HistoryEntryElement::new(entry.clone(), self.agent_panel.clone())
499                            .highlight_positions(highlight_positions)
500                            .timestamp_format(*format)
501                            .selected(list_entry_ix == Some(self.selected_index))
502                            .hovered(list_entry_ix == self.hovered_index)
503                            .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
504                                if *is_hovered {
505                                    this.hovered_index = list_entry_ix;
506                                } else if this.hovered_index == list_entry_ix {
507                                    this.hovered_index = None;
508                                }
509
510                                cx.notify();
511                            }))
512                            .into_any_element(),
513                    )
514                    .into_any(),
515                None => Empty.into_any_element(),
516            },
517            ListItemType::BucketSeparator(bucket) => div()
518                .px(DynamicSpacing::Base06.rems(cx))
519                .pt_2()
520                .pb_1()
521                .child(
522                    Label::new(bucket.to_string())
523                        .size(LabelSize::XSmall)
524                        .color(Color::Muted),
525                )
526                .into_any_element(),
527        }
528    }
529}
530
531impl Focusable for ThreadHistory {
532    fn focus_handle(&self, cx: &App) -> FocusHandle {
533        self.search_editor.focus_handle(cx)
534    }
535}
536
537impl Render for ThreadHistory {
538    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
539        v_flex()
540            .key_context("ThreadHistory")
541            .size_full()
542            .on_action(cx.listener(Self::select_previous))
543            .on_action(cx.listener(Self::select_next))
544            .on_action(cx.listener(Self::select_first))
545            .on_action(cx.listener(Self::select_last))
546            .on_action(cx.listener(Self::confirm))
547            .on_action(cx.listener(Self::remove_selected_thread))
548            .when(!self.all_entries.is_empty(), |parent| {
549                parent.child(
550                    h_flex()
551                        .h(px(41.)) // Match the toolbar perfectly
552                        .w_full()
553                        .py_1()
554                        .px_2()
555                        .gap_2()
556                        .justify_between()
557                        .border_b_1()
558                        .border_color(cx.theme().colors().border)
559                        .child(
560                            Icon::new(IconName::MagnifyingGlass)
561                                .color(Color::Muted)
562                                .size(IconSize::Small),
563                        )
564                        .child(self.search_editor.clone()),
565                )
566            })
567            .child({
568                let view = v_flex()
569                    .id("list-container")
570                    .relative()
571                    .overflow_hidden()
572                    .flex_grow();
573
574                if self.all_entries.is_empty() {
575                    view.justify_center()
576                        .child(
577                            h_flex().w_full().justify_center().child(
578                                Label::new("You don't have any past threads yet.")
579                                    .size(LabelSize::Small),
580                            ),
581                        )
582                } else if self.search_produced_no_matches() {
583                    view.justify_center().child(
584                        h_flex().w_full().justify_center().child(
585                            Label::new("No threads match your search.").size(LabelSize::Small),
586                        ),
587                    )
588                } else {
589                    view.pr_5()
590                        .child(
591                            uniform_list(
592                                cx.entity().clone(),
593                                "thread-history",
594                                self.list_item_count(),
595                                Self::list_items,
596                            )
597                            .p_1()
598                            .track_scroll(self.scroll_handle.clone())
599                            .flex_grow(),
600                        )
601                        .when_some(self.render_scrollbar(cx), |div, scrollbar| {
602                            div.child(scrollbar)
603                        })
604                }
605            })
606    }
607}
608
609#[derive(IntoElement)]
610pub struct HistoryEntryElement {
611    entry: HistoryEntry,
612    agent_panel: WeakEntity<AgentPanel>,
613    selected: bool,
614    hovered: bool,
615    highlight_positions: Vec<usize>,
616    timestamp_format: EntryTimeFormat,
617    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
618}
619
620impl HistoryEntryElement {
621    pub fn new(entry: HistoryEntry, agent_panel: WeakEntity<AgentPanel>) -> Self {
622        Self {
623            entry,
624            agent_panel,
625            selected: false,
626            hovered: false,
627            highlight_positions: vec![],
628            timestamp_format: EntryTimeFormat::DateAndTime,
629            on_hover: Box::new(|_, _, _| {}),
630        }
631    }
632
633    pub fn selected(mut self, selected: bool) -> Self {
634        self.selected = selected;
635        self
636    }
637
638    pub fn hovered(mut self, hovered: bool) -> Self {
639        self.hovered = hovered;
640        self
641    }
642
643    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
644        self.highlight_positions = positions;
645        self
646    }
647
648    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
649        self.on_hover = Box::new(on_hover);
650        self
651    }
652
653    pub fn timestamp_format(mut self, format: EntryTimeFormat) -> Self {
654        self.timestamp_format = format;
655        self
656    }
657}
658
659impl RenderOnce for HistoryEntryElement {
660    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
661        let (id, summary, timestamp) = match &self.entry {
662            HistoryEntry::Thread(thread) => (
663                thread.id.to_string(),
664                thread.summary.clone(),
665                thread.updated_at.timestamp(),
666            ),
667            HistoryEntry::Context(context) => (
668                context.path.to_string_lossy().to_string(),
669                context.title.clone().into(),
670                context.mtime.timestamp(),
671            ),
672        };
673
674        let thread_timestamp =
675            self.timestamp_format
676                .format_timestamp(&self.agent_panel, timestamp, cx);
677
678        ListItem::new(SharedString::from(id))
679            .rounded()
680            .toggle_state(self.selected)
681            .spacing(ListItemSpacing::Sparse)
682            .start_slot(
683                h_flex()
684                    .w_full()
685                    .gap_2()
686                    .justify_between()
687                    .child(
688                        HighlightedLabel::new(summary, self.highlight_positions)
689                            .size(LabelSize::Small)
690                            .truncate(),
691                    )
692                    .child(
693                        Label::new(thread_timestamp)
694                            .color(Color::Muted)
695                            .size(LabelSize::XSmall),
696                    ),
697            )
698            .on_hover(self.on_hover)
699            .end_slot::<IconButton>(if self.hovered || self.selected {
700                Some(
701                    IconButton::new("delete", IconName::TrashAlt)
702                        .shape(IconButtonShape::Square)
703                        .icon_size(IconSize::XSmall)
704                        .icon_color(Color::Muted)
705                        .tooltip(move |window, cx| {
706                            Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
707                        })
708                        .on_click({
709                            let agent_panel = self.agent_panel.clone();
710
711                            let f: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> =
712                                match &self.entry {
713                                    HistoryEntry::Thread(thread) => {
714                                        let id = thread.id.clone();
715
716                                        Box::new(move |_event, _window, cx| {
717                                            agent_panel
718                                                .update(cx, |this, cx| {
719                                                    this.delete_thread(&id, cx)
720                                                        .detach_and_log_err(cx);
721                                                })
722                                                .ok();
723                                        })
724                                    }
725                                    HistoryEntry::Context(context) => {
726                                        let path = context.path.clone();
727
728                                        Box::new(move |_event, _window, cx| {
729                                            agent_panel
730                                                .update(cx, |this, cx| {
731                                                    this.delete_context(path.clone(), cx)
732                                                        .detach_and_log_err(cx);
733                                                })
734                                                .ok();
735                                        })
736                                    }
737                                };
738                            f
739                        }),
740                )
741            } else {
742                None
743            })
744            .on_click({
745                let agent_panel = self.agent_panel.clone();
746
747                let f: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> = match &self.entry
748                {
749                    HistoryEntry::Thread(thread) => {
750                        let id = thread.id.clone();
751                        Box::new(move |_event, window, cx| {
752                            agent_panel
753                                .update(cx, |this, cx| {
754                                    this.open_thread_by_id(&id, window, cx)
755                                        .detach_and_log_err(cx);
756                                })
757                                .ok();
758                        })
759                    }
760                    HistoryEntry::Context(context) => {
761                        let path = context.path.clone();
762                        Box::new(move |_event, window, cx| {
763                            agent_panel
764                                .update(cx, |this, cx| {
765                                    this.open_saved_prompt_editor(path.clone(), window, cx)
766                                        .detach_and_log_err(cx);
767                                })
768                                .ok();
769                        })
770                    }
771                };
772                f
773            })
774    }
775}
776
777#[derive(Clone, Copy)]
778pub enum EntryTimeFormat {
779    DateAndTime,
780    TimeOnly,
781}
782
783impl EntryTimeFormat {
784    fn format_timestamp(
785        &self,
786        agent_panel: &WeakEntity<AgentPanel>,
787        timestamp: i64,
788        cx: &App,
789    ) -> String {
790        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
791        let timezone = agent_panel
792            .read_with(cx, |this, _cx| this.local_timezone())
793            .unwrap_or(UtcOffset::UTC);
794
795        match &self {
796            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
797                timestamp,
798                OffsetDateTime::now_utc(),
799                timezone,
800                time_format::TimestampFormat::EnhancedAbsolute,
801            ),
802            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
803        }
804    }
805}
806
807impl From<TimeBucket> for EntryTimeFormat {
808    fn from(bucket: TimeBucket) -> Self {
809        match bucket {
810            TimeBucket::Today => EntryTimeFormat::TimeOnly,
811            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
812            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
813            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
814            TimeBucket::All => EntryTimeFormat::DateAndTime,
815        }
816    }
817}
818
819#[derive(PartialEq, Eq, Clone, Copy, Debug)]
820enum TimeBucket {
821    Today,
822    Yesterday,
823    ThisWeek,
824    PastWeek,
825    All,
826}
827
828impl TimeBucket {
829    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
830        if date == reference {
831            return TimeBucket::Today;
832        }
833
834        if date == reference - TimeDelta::days(1) {
835            return TimeBucket::Yesterday;
836        }
837
838        let week = date.iso_week();
839
840        if reference.iso_week() == week {
841            return TimeBucket::ThisWeek;
842        }
843
844        let last_week = (reference - TimeDelta::days(7)).iso_week();
845
846        if week == last_week {
847            return TimeBucket::PastWeek;
848        }
849
850        TimeBucket::All
851    }
852}
853
854impl Display for TimeBucket {
855    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
856        match self {
857            TimeBucket::Today => write!(f, "Today"),
858            TimeBucket::Yesterday => write!(f, "Yesterday"),
859            TimeBucket::ThisWeek => write!(f, "This Week"),
860            TimeBucket::PastWeek => write!(f, "Past Week"),
861            TimeBucket::All => write!(f, "All"),
862        }
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use chrono::NaiveDate;
870
871    #[test]
872    fn test_time_bucket_from_dates() {
873        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
874
875        let date = today;
876        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
877
878        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
879        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
880
881        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
882        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
883
884        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
885        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
886
887        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
888        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
889
890        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
891        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
892
893        // All: not in this week or last week
894        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
895        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
896
897        // Test year boundary cases
898        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
899
900        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
901        assert_eq!(
902            TimeBucket::from_dates(new_year, date),
903            TimeBucket::Yesterday
904        );
905
906        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
907        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
908    }
909}