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}