1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21enum CalendarRange {
22 Yesterday,
23 Today,
24 Tomorrow,
25 ThisWeek,
27 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 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#[derive(Clone, Debug, PartialEq, Default)]
68#[non_exhaustive]
69pub enum CalendarAction {
70 #[default]
72 None,
73 SelectSymbol(String),
75}
76
77pub 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 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 pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
121 self.feed = Some(feed);
122 self.refresh();
123 }
124
125 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 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 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 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 #[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 #[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}