Skip to main content

egui_charts/ui/widget_bar/panels/
calendar.rs

1//! Economic, earnings, dividend and IPO calendars the feed supplies.
2
3use crate::localization as l10n;
4use std::sync::Arc;
5
6use chrono::{DateTime, Datelike, Days, NaiveTime, Utc};
7use egui::{RichText, Sense, Ui, vec2};
8
9use super::ROW_HEIGHT;
10use super::controls::{cell, dropdown};
11use crate::data::inbox::Inbox;
12use crate::data::{CalendarEvent, CalendarKind, CalendarQuery, Datafeed, FeedError, Importance};
13use crate::icons::icons;
14use crate::theme::ThemeExt;
15use crate::ui_kit::buttons::Button;
16use crate::ui_kit::status_message::{MessageKind, status_message, translated_status_message};
17use crate::ui_kit::{EmptyState, LoadingIndicator, PanelHeader};
18
19/// The days a calendar lists, in UTC.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21enum CalendarRange {
22    Yesterday,
23    Today,
24    Tomorrow,
25    /// Monday to Sunday of the current week.
26    ThisWeek,
27    /// Monday to Sunday of the following week.
28    NextWeek,
29}
30
31impl CalendarRange {
32    const ALL: [CalendarRange; 5] = [
33        CalendarRange::Yesterday,
34        CalendarRange::Today,
35        CalendarRange::Tomorrow,
36        CalendarRange::ThisWeek,
37        CalendarRange::NextWeek,
38    ];
39
40    fn label(self) -> &'static str {
41        match self {
42            CalendarRange::Yesterday => "Yesterday",
43            CalendarRange::Today => "Today",
44            CalendarRange::Tomorrow => "Tomorrow",
45            CalendarRange::ThisWeek => "This week",
46            CalendarRange::NextWeek => "Next week",
47        }
48    }
49
50    /// The start of the first day and the start of the day after the last, around `now`.
51    fn bounds(self, now: DateTime<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
52        let today = now.date_naive();
53        let monday = today - Days::new(u64::from(today.weekday().num_days_from_monday()));
54        let (first, days) = match self {
55            CalendarRange::Yesterday => (today - Days::new(1), 1),
56            CalendarRange::Today => (today, 1),
57            CalendarRange::Tomorrow => (today + Days::new(1), 1),
58            CalendarRange::ThisWeek => (monday, 7),
59            CalendarRange::NextWeek => (monday + Days::new(7), 7),
60        };
61        let start = |day: chrono::NaiveDate| day.and_time(NaiveTime::MIN).and_utc();
62        (start(first), start(first + Days::new(days)))
63    }
64}
65
66/// An action the host performs for the calendar panel.
67#[derive(Clone, Debug, PartialEq, Default)]
68#[non_exhaustive]
69pub enum CalendarAction {
70    /// No host action.
71    #[default]
72    None,
73    /// Show this company's symbol, from an earnings, dividend or IPO event.
74    SelectSymbol(String),
75}
76
77/// Calendars fed by the host's [`Datafeed::calendar`], one at a time.
78///
79/// The user picks the calendar, the days, and for economic events the least importance; with
80/// [`set_countries`](Self::set_countries) also the countries. Every change asks the feed again,
81/// which applies the filters, and only the answer to the latest request is shown. Call
82/// [`poll`](Self::poll) each frame, then [`show`](Self::show) while the panel is open.
83pub struct CalendarPanel {
84    kind: CalendarKind,
85    range: CalendarRange,
86    importance: Option<Importance>,
87    countries: Vec<(String, bool)>,
88    feed: Option<Arc<dyn Datafeed>>,
89    events: Vec<CalendarEvent>,
90    error: Option<FeedError>,
91    loading: bool,
92    inbox: Arc<Inbox>,
93    request: u64,
94}
95
96impl Default for CalendarPanel {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl CalendarPanel {
103    /// This week's economic calendar of every country, without a feed.
104    pub fn new() -> Self {
105        Self {
106            kind: CalendarKind::Economic,
107            range: CalendarRange::ThisWeek,
108            importance: None,
109            countries: Vec::new(),
110            feed: None,
111            events: Vec::new(),
112            error: None,
113            loading: false,
114            inbox: Arc::default(),
115            request: 0,
116        }
117    }
118
119    /// Ask `feed` for events from now on, starting at once.
120    pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
121        self.feed = Some(feed);
122        self.refresh();
123    }
124
125    /// Offer `countries`, in the codes the feed uses, as a filter. None is selected at first,
126    /// which lists every country.
127    pub fn set_countries(&mut self, countries: impl IntoIterator<Item = String>) {
128        self.countries = countries.into_iter().map(|code| (code, false)).collect();
129        self.refresh();
130    }
131
132    /// Ask the feed again, such as when the host learns of new figures.
133    pub fn refresh(&mut self) {
134        let Some(feed) = &self.feed else {
135            return;
136        };
137        self.request += 1;
138        self.loading = true;
139        let (from, to) = self.range.bounds(Utc::now());
140        let query = CalendarQuery {
141            kind: self.kind,
142            from,
143            to,
144            countries: self
145                .countries
146                .iter()
147                .filter(|(_, on)| *on)
148                .map(|(code, _)| code.clone())
149                .collect(),
150            importance: self
151                .importance
152                .filter(|_| self.kind == CalendarKind::Economic),
153        };
154        feed.calendar(query, self.inbox.reply(self.request));
155    }
156
157    /// Receive the feed's answer and wake the UI when the next one arrives.
158    pub fn poll(&mut self, ctx: &egui::Context) {
159        let repaint = ctx.clone();
160        self.inbox.set_waker(move || repaint.request_repaint());
161        for (key, answer) in self.inbox.drain().answers {
162            if key != self.request {
163                continue;
164            }
165            self.loading = false;
166            match *answer
167                .downcast::<Result<Vec<CalendarEvent>, FeedError>>()
168                .expect("calendar")
169            {
170                Ok(events) => {
171                    self.events = events;
172                    self.error = None;
173                }
174                Err(error) => {
175                    self.events.clear();
176                    self.error = Some(error);
177                }
178            }
179        }
180    }
181
182    /// Render the filters and the events by day after [`poll`](Self::poll).
183    pub fn show(&mut self, ui: &mut Ui) -> CalendarAction {
184        let mut action = CalendarAction::None;
185        let theme = ui.active_theme();
186        PanelHeader::new("Calendars").show_plain(ui);
187        let before = (
188            self.kind,
189            self.range,
190            self.importance,
191            self.countries.clone(),
192        );
193        ui.horizontal_wrapped(|ui| {
194            for (kind, label) in [
195                (CalendarKind::Economic, "Economic"),
196                (CalendarKind::Earnings, "Earnings"),
197                (CalendarKind::Dividends, "Dividends"),
198                (CalendarKind::Ipos, "IPOs"),
199            ] {
200                if Button::new(label)
201                    .small()
202                    .selected(self.kind == kind)
203                    .show(ui)
204                    .clicked()
205                {
206                    self.kind = kind;
207                }
208            }
209        });
210        ui.horizontal_wrapped(|ui| {
211            dropdown(ui, "calendar_range", self.range.label(), |ui| {
212                for range in CalendarRange::ALL {
213                    ui.selectable_value(
214                        &mut self.range,
215                        range,
216                        l10n::text(ui.ctx(), range.label()),
217                    );
218                }
219            });
220            if self.kind == CalendarKind::Economic {
221                dropdown(
222                    ui,
223                    "calendar_importance",
224                    importance_label(self.importance),
225                    |ui| {
226                        for importance in [None, Some(Importance::Medium), Some(Importance::High)] {
227                            ui.selectable_value(
228                                &mut self.importance,
229                                importance,
230                                l10n::text(ui.ctx(), importance_label(importance)),
231                            );
232                        }
233                    },
234                );
235            }
236        });
237        if !self.countries.is_empty() {
238            let chosen = self.countries.iter().filter(|(_, on)| *on).count();
239            let text = if chosen == 0 {
240                "All countries".to_owned()
241            } else {
242                l10n::format_text(
243                    ui.ctx(),
244                    "{count} countries",
245                    &[("count", chosen.to_string().as_str())],
246                )
247            };
248            dropdown(ui, "calendar_countries", &text, |ui| {
249                for (code, on) in &mut self.countries {
250                    ui.checkbox(on, l10n::text(ui.ctx(), code.as_str()));
251                }
252            });
253        }
254        if before
255            != (
256                self.kind,
257                self.range,
258                self.importance,
259                self.countries.clone(),
260            )
261        {
262            self.refresh();
263        }
264        match &self.error {
265            Some(FeedError::NotSupported) => {
266                status_message(ui, MessageKind::Info, "This feed supplies no calendar.");
267            }
268            Some(error) => {
269                translated_status_message(
270                    ui,
271                    MessageKind::Error,
272                    l10n::format_text(
273                        ui.ctx(),
274                        "Events unavailable: {error}",
275                        &[("error", error.to_string().as_str())],
276                    ),
277                );
278            }
279            None if self.loading && self.events.is_empty() => {
280                LoadingIndicator::new().message("Loading events").show(ui);
281            }
282            None if self.events.is_empty() => {
283                EmptyState::new("No events")
284                    .icon(&icons::WIDGET_BAR_CALENDAR)
285                    .fill_height()
286                    .show(ui);
287            }
288            None => {}
289        }
290        egui::ScrollArea::vertical()
291            .auto_shrink([false, false])
292            .show(ui, |ui| {
293                ui.spacing_mut().item_spacing.y = 0.0;
294                let figures = |rect: egui::Rect| {
295                    let rect = rect.shrink2(vec2(8.0, 0.0));
296                    std::array::from_fn::<_, 3, _>(|i| {
297                        rect.with_min_x(rect.left() + rect.width() * i as f32 / 3.0)
298                            .with_max_x(rect.left() + rect.width() * (i + 1) as f32 / 3.0 - 4.0)
299                    })
300                };
301                let mut day = None;
302                for event in &self.events {
303                    if day != Some(event.time.date_naive()) {
304                        day = Some(event.time.date_naive());
305                        ui.add_space(theme.spacing.sm);
306                        ui.label(
307                            RichText::new(l10n::text(
308                                ui.ctx(),
309                                event.time.format("%A, %b %-d").to_string(),
310                            ))
311                            .size(theme.text.small)
312                            .strong()
313                            .color(theme.ui.text_secondary),
314                        );
315                        ui.separator();
316                        let (header, _) = ui.allocate_exact_size(
317                            vec2(ui.available_width(), ROW_HEIGHT),
318                            Sense::hover(),
319                        );
320                        for (rect, title) in figures(header)
321                            .into_iter()
322                            .zip(["Actual", "Forecast", "Prior"])
323                        {
324                            cell(ui, rect, title, true, theme.ui.text_secondary);
325                        }
326                    }
327                    let sense = if event.symbol.is_some() {
328                        Sense::click()
329                    } else {
330                        Sense::hover()
331                    };
332                    let (rect, response) =
333                        ui.allocate_exact_size(vec2(ui.available_width(), 2.0 * ROW_HEIGHT), sense);
334                    if response.hovered() {
335                        ui.painter().rect_filled(rect, 0.0, theme.ui.btn_bg_hover);
336                    }
337                    let metadata = rect
338                        .with_max_y(rect.top() + ROW_HEIGHT)
339                        .shrink2(vec2(8.0, 0.0));
340                    let time = metadata.with_max_x(metadata.left() + 42.0);
341                    let country = metadata
342                        .with_min_x(time.right())
343                        .with_max_x(time.right() + 28.0);
344                    let importance = metadata
345                        .with_min_x(country.right())
346                        .with_max_x(country.right() + 32.0);
347                    let title_rect = metadata.with_min_x(importance.right());
348                    cell(
349                        ui,
350                        time,
351                        &event.time.format("%H:%M").to_string(),
352                        false,
353                        theme.ui.text_secondary,
354                    );
355                    cell(ui, country, &event.country, false, theme.ui.text_secondary);
356                    if let Some(level) = event.importance {
357                        let color = match level {
358                            Importance::High => theme.ui.error,
359                            Importance::Medium => theme.ui.warning,
360                            Importance::Low => theme.ui.text_secondary,
361                        };
362                        cell(
363                            ui,
364                            importance,
365                            &"●".repeat(level as usize + 1),
366                            false,
367                            color,
368                        )
369                        .on_hover_text(l10n::text(
370                            ui.ctx(),
371                            match level {
372                                Importance::Low => "Low importance",
373                                Importance::Medium => "Medium importance",
374                                Importance::High => "High importance",
375                            },
376                        ));
377                    }
378                    let title = match &event.symbol {
379                        Some(symbol) => format!("{symbol} · {}", event.title),
380                        None => event.title.clone(),
381                    };
382                    cell(ui, title_rect, &title, false, theme.ui.text);
383                    for (rect, value) in figures(rect.with_min_y(rect.top() + ROW_HEIGHT))
384                        .into_iter()
385                        .zip([&event.actual, &event.forecast, &event.prior])
386                    {
387                        cell(
388                            ui,
389                            rect,
390                            value.as_deref().unwrap_or("-"),
391                            true,
392                            theme.ui.text,
393                        );
394                    }
395                    if let Some(symbol) = &event.symbol {
396                        let translated_label = l10n::text(ui.ctx(), &title);
397                        response.widget_info(|| {
398                            egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label)
399                        });
400                        if response
401                            .on_hover_cursor(egui::CursorIcon::PointingHand)
402                            .clicked()
403                        {
404                            action = CalendarAction::SelectSymbol(symbol.clone());
405                        }
406                    }
407                }
408            });
409        action
410    }
411}
412
413fn importance_label(importance: Option<Importance>) -> &'static str {
414    match importance {
415        None | Some(Importance::Low) => "All importance",
416        Some(Importance::Medium) => "Medium and high",
417        Some(Importance::High) => "High",
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    /// Missing figures keep their columns, and event metadata fits on one line.
426    #[test]
427    fn event_figures_align_under_their_headings() {
428        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
429            let ctx = egui::Context::default();
430            crate::theme::apply_to_egui(&ctx, &theme);
431            let mut panel = CalendarPanel::new();
432            let mut event = CalendarEvent::new(
433                "release",
434                CalendarKind::Economic,
435                "2026-10-21T12:30:00Z".parse().unwrap(),
436                "Output",
437            );
438            event.country = "US".into();
439            event.importance = Some(Importance::High);
440            event.actual = Some("1.2%".into());
441            event.prior = Some("10.5%".into());
442            panel.events.push(event);
443            let output = ctx.run_ui(
444                egui::RawInput {
445                    screen_rect: Some(egui::Rect::from_min_size(
446                        egui::Pos2::ZERO,
447                        egui::vec2(280.0, 400.0),
448                    )),
449                    ..Default::default()
450                },
451                |ui| {
452                    panel.show(ui);
453                },
454            );
455            let texts: Vec<_> = output
456                .shapes
457                .iter()
458                .filter_map(|shape| {
459                    let egui::Shape::Text(text) = &shape.shape else {
460                        return None;
461                    };
462                    Some((
463                        text.galley.text().to_owned(),
464                        text.visual_bounding_rect().intersect(shape.clip_rect),
465                    ))
466                })
467                .collect();
468            output.drop_without_applying_deltas();
469            let bounds = |label: &str| {
470                texts
471                    .iter()
472                    .find(|(text, _)| text == label)
473                    .unwrap_or_else(|| panic!("missing {label}: {texts:?}"))
474                    .1
475            };
476            for (value, heading) in [("1.2%", "Actual"), ("10.5%", "Prior")] {
477                assert!(
478                    (bounds(value).right() - bounds(heading).right()).abs() < 2.0,
479                    "{texts:?}"
480                );
481            }
482            for label in ["US", "Output"] {
483                assert!(
484                    (bounds(label).center().y - bounds("12:30").center().y).abs() < 2.0,
485                    "{texts:?}"
486                );
487            }
488            assert!(bounds("1.2%").right() < bounds("10.5%").left());
489        }
490    }
491
492    /// Guards the days each range asks for: whole UTC days from midnight, weeks from Monday, so a
493    /// Sunday belongs to the week that ends with it. Worked by hand from a calendar: 2026-10-21 is
494    /// a Wednesday and 2026-10-25 a Sunday.
495    #[test]
496    fn ranges_cover_whole_utc_days_and_monday_weeks() {
497        let at = |text: &str| text.parse::<DateTime<Utc>>().unwrap();
498        for (now, range, from, to) in [
499            (
500                "2026-10-21T15:30:00Z",
501                CalendarRange::Yesterday,
502                "2026-10-20T00:00:00Z",
503                "2026-10-21T00:00:00Z",
504            ),
505            (
506                "2026-10-21T15:30:00Z",
507                CalendarRange::Today,
508                "2026-10-21T00:00:00Z",
509                "2026-10-22T00:00:00Z",
510            ),
511            (
512                "2026-10-21T15:30:00Z",
513                CalendarRange::Tomorrow,
514                "2026-10-22T00:00:00Z",
515                "2026-10-23T00:00:00Z",
516            ),
517            (
518                "2026-10-21T15:30:00Z",
519                CalendarRange::ThisWeek,
520                "2026-10-19T00:00:00Z",
521                "2026-10-26T00:00:00Z",
522            ),
523            (
524                "2026-10-21T15:30:00Z",
525                CalendarRange::NextWeek,
526                "2026-10-26T00:00:00Z",
527                "2026-11-02T00:00:00Z",
528            ),
529            (
530                "2026-10-25T23:59:59Z",
531                CalendarRange::ThisWeek,
532                "2026-10-19T00:00:00Z",
533                "2026-10-26T00:00:00Z",
534            ),
535            (
536                "2026-10-26T00:00:00Z",
537                CalendarRange::ThisWeek,
538                "2026-10-26T00:00:00Z",
539                "2026-11-02T00:00:00Z",
540            ),
541            (
542                "2026-12-31T08:00:00Z",
543                CalendarRange::NextWeek,
544                "2027-01-04T00:00:00Z",
545                "2027-01-11T00:00:00Z",
546            ),
547        ] {
548            assert_eq!(
549                range.bounds(at(now)),
550                (at(from), at(to)),
551                "{range:?} at {now}"
552            );
553        }
554    }
555}