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