thread_history.rs

  1use crate::acp::AcpThreadView;
  2use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
  3use agent::{DbThreadMetadata, ThreadStore};
  4use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
  5use editor::{Editor, EditorEvent};
  6use fuzzy::StringMatchCandidate;
  7use gpui::{
  8    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
  9    UniformListScrollHandle, WeakEntity, Window, uniform_list,
 10};
 11use std::{fmt::Display, ops::Range};
 12use text::Bias;
 13use time::{OffsetDateTime, UtcOffset};
 14use ui::{
 15    ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
 16    WithScrollbar, prelude::*,
 17};
 18
 19const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
 20
 21fn thread_title(entry: &DbThreadMetadata) -> &SharedString {
 22    if entry.title.is_empty() {
 23        DEFAULT_TITLE
 24    } else {
 25        &entry.title
 26    }
 27}
 28
 29pub struct AcpThreadHistory {
 30    pub(crate) thread_store: Entity<ThreadStore>,
 31    scroll_handle: UniformListScrollHandle,
 32    selected_index: usize,
 33    hovered_index: Option<usize>,
 34    search_editor: Entity<Editor>,
 35    search_query: SharedString,
 36    visible_items: Vec<ListItemType>,
 37    local_timezone: UtcOffset,
 38    confirming_delete_history: bool,
 39    _update_task: Task<()>,
 40    _subscriptions: Vec<gpui::Subscription>,
 41}
 42
 43enum ListItemType {
 44    BucketSeparator(TimeBucket),
 45    Entry {
 46        entry: DbThreadMetadata,
 47        format: EntryTimeFormat,
 48    },
 49    SearchResult {
 50        entry: DbThreadMetadata,
 51        positions: Vec<usize>,
 52    },
 53}
 54
 55impl ListItemType {
 56    fn history_entry(&self) -> Option<&DbThreadMetadata> {
 57        match self {
 58            ListItemType::Entry { entry, .. } => Some(entry),
 59            ListItemType::SearchResult { entry, .. } => Some(entry),
 60            _ => None,
 61        }
 62    }
 63}
 64
 65pub enum ThreadHistoryEvent {
 66    Open(DbThreadMetadata),
 67}
 68
 69impl EventEmitter<ThreadHistoryEvent> for AcpThreadHistory {}
 70
 71impl AcpThreadHistory {
 72    pub(crate) fn new(
 73        thread_store: Entity<ThreadStore>,
 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...", window, 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                    if this.search_query != query {
 88                        this.search_query = query.into();
 89                        this.update_visible_items(false, cx);
 90                    }
 91                }
 92            });
 93
 94        let thread_store_subscription = cx.observe(&thread_store, |this, _, cx| {
 95            this.update_visible_items(true, cx);
 96        });
 97
 98        let scroll_handle = UniformListScrollHandle::default();
 99
100        let mut this = Self {
101            thread_store,
102            scroll_handle,
103            selected_index: 0,
104            hovered_index: None,
105            visible_items: Default::default(),
106            search_editor,
107            local_timezone: UtcOffset::from_whole_seconds(
108                chrono::Local::now().offset().local_minus_utc(),
109            )
110            .unwrap(),
111            search_query: SharedString::default(),
112            confirming_delete_history: false,
113            _subscriptions: vec![search_editor_subscription, thread_store_subscription],
114            _update_task: Task::ready(()),
115        };
116        this.update_visible_items(false, cx);
117        this
118    }
119
120    fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
121        let entries = self
122            .thread_store
123            .update(cx, |store, _| store.entries().collect());
124        let new_list_items = if self.search_query.is_empty() {
125            self.add_list_separators(entries, cx)
126        } else {
127            self.filter_search_results(entries, cx)
128        };
129        let selected_history_entry = if preserve_selected_item {
130            self.selected_history_entry().cloned()
131        } else {
132            None
133        };
134
135        self._update_task = cx.spawn(async move |this, cx| {
136            let new_visible_items = new_list_items.await;
137            this.update(cx, |this, cx| {
138                let new_selected_index = if let Some(history_entry) = selected_history_entry {
139                    new_visible_items
140                        .iter()
141                        .position(|visible_entry| {
142                            visible_entry
143                                .history_entry()
144                                .is_some_and(|entry| entry.id == history_entry.id)
145                        })
146                        .unwrap_or(0)
147                } else {
148                    0
149                };
150
151                this.visible_items = new_visible_items;
152                this.set_selected_index(new_selected_index, Bias::Right, cx);
153                cx.notify();
154            })
155            .ok();
156        });
157    }
158
159    fn add_list_separators(
160        &self,
161        entries: Vec<DbThreadMetadata>,
162        cx: &App,
163    ) -> Task<Vec<ListItemType>> {
164        cx.background_spawn(async move {
165            let mut items = Vec::with_capacity(entries.len() + 1);
166            let mut bucket = None;
167            let today = Local::now().naive_local().date();
168
169            for entry in entries.into_iter() {
170                let entry_date = entry.updated_at.with_timezone(&Local).naive_local().date();
171                let entry_bucket = TimeBucket::from_dates(today, entry_date);
172
173                if Some(entry_bucket) != bucket {
174                    bucket = Some(entry_bucket);
175                    items.push(ListItemType::BucketSeparator(entry_bucket));
176                }
177
178                items.push(ListItemType::Entry {
179                    entry,
180                    format: entry_bucket.into(),
181                });
182            }
183            items
184        })
185    }
186
187    fn filter_search_results(
188        &self,
189        entries: Vec<DbThreadMetadata>,
190        cx: &App,
191    ) -> Task<Vec<ListItemType>> {
192        let query = self.search_query.clone();
193        cx.background_spawn({
194            let executor = cx.background_executor().clone();
195            async move {
196                let mut candidates = Vec::with_capacity(entries.len());
197
198                for (idx, entry) in entries.iter().enumerate() {
199                    candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
200                }
201
202                const MAX_MATCHES: usize = 100;
203
204                let matches = fuzzy::match_strings(
205                    &candidates,
206                    &query,
207                    false,
208                    true,
209                    MAX_MATCHES,
210                    &Default::default(),
211                    executor,
212                )
213                .await;
214
215                matches
216                    .into_iter()
217                    .map(|search_match| ListItemType::SearchResult {
218                        entry: entries[search_match.candidate_id].clone(),
219                        positions: search_match.positions,
220                    })
221                    .collect()
222            }
223        })
224    }
225
226    fn search_produced_no_matches(&self) -> bool {
227        self.visible_items.is_empty() && !self.search_query.is_empty()
228    }
229
230    fn selected_history_entry(&self) -> Option<&DbThreadMetadata> {
231        self.get_history_entry(self.selected_index)
232    }
233
234    fn get_history_entry(&self, visible_items_ix: usize) -> Option<&DbThreadMetadata> {
235        self.visible_items.get(visible_items_ix)?.history_entry()
236    }
237
238    fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
239        if self.visible_items.len() == 0 {
240            self.selected_index = 0;
241            return;
242        }
243        while matches!(
244            self.visible_items.get(index),
245            None | Some(ListItemType::BucketSeparator(..))
246        ) {
247            index = match bias {
248                Bias::Left => {
249                    if index == 0 {
250                        self.visible_items.len() - 1
251                    } else {
252                        index - 1
253                    }
254                }
255                Bias::Right => {
256                    if index >= self.visible_items.len() - 1 {
257                        0
258                    } else {
259                        index + 1
260                    }
261                }
262            };
263        }
264        self.selected_index = index;
265        self.scroll_handle
266            .scroll_to_item(index, ScrollStrategy::Top);
267        cx.notify()
268    }
269
270    pub fn select_previous(
271        &mut self,
272        _: &menu::SelectPrevious,
273        _window: &mut Window,
274        cx: &mut Context<Self>,
275    ) {
276        if self.selected_index == 0 {
277            self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
278        } else {
279            self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
280        }
281    }
282
283    pub fn select_next(
284        &mut self,
285        _: &menu::SelectNext,
286        _window: &mut Window,
287        cx: &mut Context<Self>,
288    ) {
289        if self.selected_index == self.visible_items.len() - 1 {
290            self.set_selected_index(0, Bias::Right, cx);
291        } else {
292            self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
293        }
294    }
295
296    fn select_first(
297        &mut self,
298        _: &menu::SelectFirst,
299        _window: &mut Window,
300        cx: &mut Context<Self>,
301    ) {
302        self.set_selected_index(0, Bias::Right, cx);
303    }
304
305    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
306        self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
307    }
308
309    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
310        self.confirm_entry(self.selected_index, cx);
311    }
312
313    fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
314        let Some(entry) = self.get_history_entry(ix) else {
315            return;
316        };
317        cx.emit(ThreadHistoryEvent::Open(entry.clone()));
318    }
319
320    fn remove_selected_thread(
321        &mut self,
322        _: &RemoveSelectedThread,
323        _window: &mut Window,
324        cx: &mut Context<Self>,
325    ) {
326        self.remove_thread(self.selected_index, cx)
327    }
328
329    fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
330        let Some(entry) = self.get_history_entry(visible_item_ix) else {
331            return;
332        };
333
334        let task = self
335            .thread_store
336            .update(cx, |store, cx| store.delete_thread(entry.id.clone(), cx));
337        task.detach_and_log_err(cx);
338    }
339
340    fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
341        self.thread_store.update(cx, |store, cx| {
342            store.delete_threads(cx).detach_and_log_err(cx)
343        });
344        self.confirming_delete_history = false;
345        cx.notify();
346    }
347
348    fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
349        self.confirming_delete_history = true;
350        cx.notify();
351    }
352
353    fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
354        self.confirming_delete_history = false;
355        cx.notify();
356    }
357
358    fn render_list_items(
359        &mut self,
360        range: Range<usize>,
361        _window: &mut Window,
362        cx: &mut Context<Self>,
363    ) -> Vec<AnyElement> {
364        self.visible_items
365            .get(range.clone())
366            .into_iter()
367            .flatten()
368            .enumerate()
369            .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
370            .collect()
371    }
372
373    fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
374        match item {
375            ListItemType::Entry { entry, format } => self
376                .render_history_entry(entry, *format, ix, Vec::default(), cx)
377                .into_any(),
378            ListItemType::SearchResult { entry, positions } => self.render_history_entry(
379                entry,
380                EntryTimeFormat::DateAndTime,
381                ix,
382                positions.clone(),
383                cx,
384            ),
385            ListItemType::BucketSeparator(bucket) => div()
386                .px(DynamicSpacing::Base06.rems(cx))
387                .pt_2()
388                .pb_1()
389                .child(
390                    Label::new(bucket.to_string())
391                        .size(LabelSize::XSmall)
392                        .color(Color::Muted),
393                )
394                .into_any_element(),
395        }
396    }
397
398    fn render_history_entry(
399        &self,
400        entry: &DbThreadMetadata,
401        format: EntryTimeFormat,
402        ix: usize,
403        highlight_positions: Vec<usize>,
404        cx: &Context<Self>,
405    ) -> AnyElement {
406        let selected = ix == self.selected_index;
407        let hovered = Some(ix) == self.hovered_index;
408        let timestamp = entry.updated_at.timestamp();
409
410        let display_text = match format {
411            EntryTimeFormat::DateAndTime => {
412                let entry_time = entry.updated_at;
413                let now = Utc::now();
414                let duration = now.signed_duration_since(entry_time);
415                let days = duration.num_days();
416
417                format!("{}d", days)
418            }
419            EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone),
420        };
421
422        let title = thread_title(entry).clone();
423        let full_date =
424            EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone);
425
426        h_flex()
427            .w_full()
428            .pb_1()
429            .child(
430                ListItem::new(ix)
431                    .rounded()
432                    .toggle_state(selected)
433                    .spacing(ListItemSpacing::Sparse)
434                    .start_slot(
435                        h_flex()
436                            .w_full()
437                            .gap_2()
438                            .justify_between()
439                            .child(
440                                HighlightedLabel::new(thread_title(entry), highlight_positions)
441                                    .size(LabelSize::Small)
442                                    .truncate(),
443                            )
444                            .child(
445                                Label::new(display_text)
446                                    .color(Color::Muted)
447                                    .size(LabelSize::XSmall),
448                            ),
449                    )
450                    .tooltip(move |_, cx| {
451                        Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
452                    })
453                    .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
454                        if *is_hovered {
455                            this.hovered_index = Some(ix);
456                        } else if this.hovered_index == Some(ix) {
457                            this.hovered_index = None;
458                        }
459
460                        cx.notify();
461                    }))
462                    .end_slot::<IconButton>(if hovered {
463                        Some(
464                            IconButton::new("delete", IconName::Trash)
465                                .shape(IconButtonShape::Square)
466                                .icon_size(IconSize::XSmall)
467                                .icon_color(Color::Muted)
468                                .tooltip(move |_window, cx| {
469                                    Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
470                                })
471                                .on_click(cx.listener(move |this, _, _, cx| {
472                                    this.remove_thread(ix, cx);
473                                    cx.stop_propagation()
474                                })),
475                        )
476                    } else {
477                        None
478                    })
479                    .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
480            )
481            .into_any_element()
482    }
483}
484
485impl Focusable for AcpThreadHistory {
486    fn focus_handle(&self, cx: &App) -> FocusHandle {
487        self.search_editor.focus_handle(cx)
488    }
489}
490
491impl Render for AcpThreadHistory {
492    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
493        let has_no_history = self.thread_store.read(cx).is_empty();
494
495        v_flex()
496            .key_context("ThreadHistory")
497            .size_full()
498            .bg(cx.theme().colors().panel_background)
499            .on_action(cx.listener(Self::select_previous))
500            .on_action(cx.listener(Self::select_next))
501            .on_action(cx.listener(Self::select_first))
502            .on_action(cx.listener(Self::select_last))
503            .on_action(cx.listener(Self::confirm))
504            .on_action(cx.listener(Self::remove_selected_thread))
505            .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
506                this.remove_history(window, cx);
507            }))
508            .child(
509                h_flex()
510                    .h(Tab::container_height(cx))
511                    .w_full()
512                    .py_1()
513                    .px_2()
514                    .gap_2()
515                    .justify_between()
516                    .border_b_1()
517                    .border_color(cx.theme().colors().border)
518                    .child(
519                        Icon::new(IconName::MagnifyingGlass)
520                            .color(Color::Muted)
521                            .size(IconSize::Small),
522                    )
523                    .child(self.search_editor.clone()),
524            )
525            .child({
526                let view = v_flex()
527                    .id("list-container")
528                    .relative()
529                    .overflow_hidden()
530                    .flex_grow();
531
532                if has_no_history {
533                    view.justify_center().items_center().child(
534                        Label::new("You don't have any past threads yet.")
535                            .size(LabelSize::Small)
536                            .color(Color::Muted),
537                    )
538                } else if self.search_produced_no_matches() {
539                    view.justify_center()
540                        .items_center()
541                        .child(Label::new("No threads match your search.").size(LabelSize::Small))
542                } else {
543                    view.child(
544                        uniform_list(
545                            "thread-history",
546                            self.visible_items.len(),
547                            cx.processor(|this, range: Range<usize>, window, cx| {
548                                this.render_list_items(range, window, cx)
549                            }),
550                        )
551                        .p_1()
552                        .pr_4()
553                        .track_scroll(&self.scroll_handle)
554                        .flex_grow(),
555                    )
556                    .vertical_scrollbar_for(&self.scroll_handle, window, cx)
557                }
558            })
559            .when(!has_no_history, |this| {
560                this.child(
561                    h_flex()
562                        .p_2()
563                        .border_t_1()
564                        .border_color(cx.theme().colors().border_variant)
565                        .when(!self.confirming_delete_history, |this| {
566                            this.child(
567                                Button::new("delete_history", "Delete All History")
568                                    .full_width()
569                                    .style(ButtonStyle::Outlined)
570                                    .label_size(LabelSize::Small)
571                                    .on_click(cx.listener(|this, _, window, cx| {
572                                        this.prompt_delete_history(window, cx);
573                                    })),
574                            )
575                        })
576                        .when(self.confirming_delete_history, |this| {
577                            this.w_full()
578                                .gap_2()
579                                .flex_wrap()
580                                .justify_between()
581                                .child(
582                                    h_flex()
583                                        .flex_wrap()
584                                        .gap_1()
585                                        .child(
586                                            Label::new("Delete all threads?")
587                                                .size(LabelSize::Small),
588                                        )
589                                        .child(
590                                            Label::new("You won't be able to recover them later.")
591                                                .size(LabelSize::Small)
592                                                .color(Color::Muted),
593                                        ),
594                                )
595                                .child(
596                                    h_flex()
597                                        .gap_1()
598                                        .child(
599                                            Button::new("cancel_delete", "Cancel")
600                                                .label_size(LabelSize::Small)
601                                                .on_click(cx.listener(|this, _, window, cx| {
602                                                    this.cancel_delete_history(window, cx);
603                                                })),
604                                        )
605                                        .child(
606                                            Button::new("confirm_delete", "Delete")
607                                                .style(ButtonStyle::Tinted(ui::TintColor::Error))
608                                                .color(Color::Error)
609                                                .label_size(LabelSize::Small)
610                                                .on_click(cx.listener(|_, _, window, cx| {
611                                                    window.dispatch_action(
612                                                        Box::new(RemoveHistory),
613                                                        cx,
614                                                    );
615                                                })),
616                                        ),
617                                )
618                        }),
619                )
620            })
621    }
622}
623
624#[derive(IntoElement)]
625pub struct AcpHistoryEntryElement {
626    entry: DbThreadMetadata,
627    thread_view: WeakEntity<AcpThreadView>,
628    selected: bool,
629    hovered: bool,
630    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
631}
632
633impl AcpHistoryEntryElement {
634    pub fn new(entry: DbThreadMetadata, thread_view: WeakEntity<AcpThreadView>) -> Self {
635        Self {
636            entry,
637            thread_view,
638            selected: false,
639            hovered: false,
640            on_hover: Box::new(|_, _, _| {}),
641        }
642    }
643
644    pub fn hovered(mut self, hovered: bool) -> Self {
645        self.hovered = hovered;
646        self
647    }
648
649    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
650        self.on_hover = Box::new(on_hover);
651        self
652    }
653}
654
655impl RenderOnce for AcpHistoryEntryElement {
656    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
657        let id = ElementId::Name(self.entry.id.0.clone().into());
658        let title = thread_title(&self.entry).clone();
659        let timestamp = self.entry.updated_at;
660
661        let formatted_time = {
662            let now = chrono::Utc::now();
663            let duration = now.signed_duration_since(timestamp);
664
665            if duration.num_days() > 0 {
666                format!("{}d", duration.num_days())
667            } else if duration.num_hours() > 0 {
668                format!("{}h ago", duration.num_hours())
669            } else if duration.num_minutes() > 0 {
670                format!("{}m ago", duration.num_minutes())
671            } else {
672                "Just now".to_string()
673            }
674        };
675
676        ListItem::new(id)
677            .rounded()
678            .toggle_state(self.selected)
679            .spacing(ListItemSpacing::Sparse)
680            .start_slot(
681                h_flex()
682                    .w_full()
683                    .gap_2()
684                    .justify_between()
685                    .child(Label::new(title).size(LabelSize::Small).truncate())
686                    .child(
687                        Label::new(formatted_time)
688                            .color(Color::Muted)
689                            .size(LabelSize::XSmall),
690                    ),
691            )
692            .on_hover(self.on_hover)
693            .end_slot::<IconButton>(if self.hovered || self.selected {
694                Some(
695                    IconButton::new("delete", IconName::Trash)
696                        .shape(IconButtonShape::Square)
697                        .icon_size(IconSize::XSmall)
698                        .icon_color(Color::Muted)
699                        .tooltip(move |_window, cx| {
700                            Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
701                        })
702                        .on_click({
703                            let thread_view = self.thread_view.clone();
704                            let entry = self.entry.clone();
705
706                            move |_event, _window, cx| {
707                                if let Some(thread_view) = thread_view.upgrade() {
708                                    thread_view.update(cx, |thread_view, cx| {
709                                        thread_view.delete_history_entry(entry.clone(), cx);
710                                    });
711                                }
712                            }
713                        }),
714                )
715            } else {
716                None
717            })
718            .on_click({
719                let thread_view = self.thread_view.clone();
720                let entry = self.entry;
721
722                move |_event, window, cx| {
723                    if let Some(workspace) = thread_view
724                        .upgrade()
725                        .and_then(|view| view.read(cx).workspace().upgrade())
726                    {
727                        if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
728                            panel.update(cx, |panel, cx| {
729                                panel.load_agent_thread(entry.clone(), window, cx);
730                            });
731                        }
732                    }
733                }
734            })
735    }
736}
737
738#[derive(Clone, Copy)]
739pub enum EntryTimeFormat {
740    DateAndTime,
741    TimeOnly,
742}
743
744impl EntryTimeFormat {
745    fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
746        let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
747
748        match self {
749            EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
750                timestamp,
751                OffsetDateTime::now_utc(),
752                timezone,
753                time_format::TimestampFormat::EnhancedAbsolute,
754            ),
755            EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
756        }
757    }
758}
759
760impl From<TimeBucket> for EntryTimeFormat {
761    fn from(bucket: TimeBucket) -> Self {
762        match bucket {
763            TimeBucket::Today => EntryTimeFormat::TimeOnly,
764            TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
765            TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
766            TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
767            TimeBucket::All => EntryTimeFormat::DateAndTime,
768        }
769    }
770}
771
772#[derive(PartialEq, Eq, Clone, Copy, Debug)]
773enum TimeBucket {
774    Today,
775    Yesterday,
776    ThisWeek,
777    PastWeek,
778    All,
779}
780
781impl TimeBucket {
782    fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
783        if date == reference {
784            return TimeBucket::Today;
785        }
786
787        if date == reference - TimeDelta::days(1) {
788            return TimeBucket::Yesterday;
789        }
790
791        let week = date.iso_week();
792
793        if reference.iso_week() == week {
794            return TimeBucket::ThisWeek;
795        }
796
797        let last_week = (reference - TimeDelta::days(7)).iso_week();
798
799        if week == last_week {
800            return TimeBucket::PastWeek;
801        }
802
803        TimeBucket::All
804    }
805}
806
807impl Display for TimeBucket {
808    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
809        match self {
810            TimeBucket::Today => write!(f, "Today"),
811            TimeBucket::Yesterday => write!(f, "Yesterday"),
812            TimeBucket::ThisWeek => write!(f, "This Week"),
813            TimeBucket::PastWeek => write!(f, "Past Week"),
814            TimeBucket::All => write!(f, "All"),
815        }
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use chrono::NaiveDate;
823
824    #[test]
825    fn test_time_bucket_from_dates() {
826        let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
827
828        let date = today;
829        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
830
831        let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
832        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
833
834        let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
835        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
836
837        let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
838        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
839
840        let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
841        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
842
843        let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
844        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
845
846        // All: not in this week or last week
847        let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
848        assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
849
850        // Test year boundary cases
851        let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
852
853        let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
854        assert_eq!(
855            TimeBucket::from_dates(new_year, date),
856            TimeBucket::Yesterday
857        );
858
859        let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
860        assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
861    }
862}