thread_history.rs

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