Skip to main content

egui_charts/ui/dialogs/alert_dialog/
dialog.rs

1//! Typed alert editing. Hosts supply output identifiers, watchlists and optional preset storage.
2
3use crate::localization as l10n;
4use std::collections::BTreeMap;
5use std::sync::Arc;
6
7use chrono::{DateTime, Months, NaiveDate, NaiveTime, Offset, TimeZone, Timelike, Utc, Weekday};
8use egui::{Context, Ui};
9
10use super::{AlertDialogAction, AlertDialogConfig};
11use crate::data::{FeedError, inbox::Inbox};
12use crate::model::{
13    Alert, AlertChannelComparison, AlertComparison, AlertCondition, AlertFrequency, AlertRule,
14    AlertSource, NotificationSchedule, NotificationWindow, TimezoneId,
15};
16use crate::services::{Storage, StoreKind};
17use crate::ui_kit::FormGrid;
18use crate::ui_kit::dialog::{DialogFooter, dialog_header};
19use crate::ui_kit::modal::Modal;
20use crate::ui_kit::status_message::{MessageKind, status_message};
21
22/// A dialog that creates or edits the same alert type the host stores and evaluates.
23pub struct AlertDialog {
24    /// Layout dimensions.
25    pub config: AlertDialogConfig,
26    /// Whether the modal is visible.
27    pub is_open: bool,
28    /// Staged edits. Cancel never submits them.
29    pub alert: Alert,
30    /// Host-provided study outputs and drawings, as stable identifiers and display names.
31    pub sources: Vec<(AlertSource, String)>,
32    /// Host-provided watchlist identifiers and display names.
33    pub watchlists: Vec<(String, String)>,
34    /// The zone the expiry date and time are entered in, UTC by default. Set it before
35    /// [`open`](Self::open) or [`edit`](Self::edit), usually to the chart's time zone.
36    pub timezone: TimezoneId,
37    /// Whether the staged alert expires; without an expiry it stays active until removed.
38    expires: bool,
39    /// The expiry date as `YYYY-MM-DD` in [`timezone`](Self::timezone).
40    expiry_date: String,
41    /// The expiry time as `HH:MM` in [`timezone`](Self::timezone).
42    expiry_time: String,
43    /// Whether the staged alert limits when notifications are delivered.
44    scheduled: bool,
45    /// The schedule's zone name.
46    schedule_zone: String,
47    /// The selected weekdays, Monday first.
48    schedule_days: [bool; 7],
49    /// The windows as `HH:MM` start and end texts.
50    schedule_windows: Vec<[String; 2]>,
51    preset_name: String,
52    presets: BTreeMap<String, Alert>,
53    storage: Option<Arc<dyn Storage>>,
54    inbox: Arc<Inbox>,
55    pending: bool,
56    loaded: bool,
57    error: Option<String>,
58}
59
60impl Default for AlertDialog {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl AlertDialog {
67    /// An initially closed dialog, without host catalogs or storage.
68    pub fn new() -> Self {
69        Self {
70            config: AlertDialogConfig::default(),
71            is_open: false,
72            alert: Alert::new("", AlertCondition::Crossing(0.0)),
73            sources: Vec::new(),
74            watchlists: Vec::new(),
75            timezone: TimezoneId::utc(),
76            expires: false,
77            expiry_date: String::new(),
78            expiry_time: String::new(),
79            scheduled: false,
80            schedule_zone: String::new(),
81            schedule_days: [false; 7],
82            schedule_windows: Vec::new(),
83            preset_name: String::new(),
84            presets: BTreeMap::new(),
85            storage: None,
86            inbox: Arc::default(),
87            pending: false,
88            loaded: false,
89            error: None,
90        }
91    }
92
93    /// Load presets from the host storage service. A failed load never permits overwriting it.
94    pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
95        self.inbox = Arc::default();
96        self.presets.clear();
97        self.error = None;
98        self.loaded = false;
99        self.pending = true;
100        storage.load(StoreKind::AlertPresets, "presets", self.inbox.reply(1));
101        self.storage = Some(storage);
102    }
103
104    /// Open a fresh price alert at the last or hovered price, discarding earlier cancelled edits.
105    pub fn open(&mut self, symbol: String, price: f64) {
106        self.edit(Alert::new(symbol, AlertCondition::Crossing(price)));
107    }
108
109    /// Stage an existing alert, retaining its host identifier and status when saved.
110    pub fn edit(&mut self, alert: Alert) {
111        if let Some(inputs) = &alert.inputs {
112            self.sources = inputs.sources.clone();
113        }
114
115        self.expires = alert.expires.is_some();
116        self.set_expiry_fields(alert.expires);
117        self.set_schedule_fields(alert.schedule.as_deref());
118        self.alert = alert;
119        self.is_open = true;
120    }
121
122    /// Fill the expiry date and time fields with `expiry` in the entry zone, or clear them.
123    fn set_expiry_fields(&mut self, expiry: Option<DateTime<Utc>>) {
124        let local = expiry.map(|t| t.with_timezone(&self.timezone));
125        let format = |pattern| {
126            local
127                .as_ref()
128                .map_or_else(String::new, |t| t.format(pattern).to_string())
129        };
130        self.expiry_date = format("%Y-%m-%d");
131        self.expiry_time = format("%H:%M");
132    }
133
134    /// The staged expiry: none without one, otherwise the entered date and time in the entry
135    /// zone, which must exist there and lie after `now`.
136    fn expiry(&self, now: DateTime<Utc>) -> Result<Option<DateTime<Utc>>, &'static str> {
137        if !self.expires {
138            return Ok(None);
139        }
140        let date = NaiveDate::parse_from_str(self.expiry_date.trim(), "%Y-%m-%d")
141            .map_err(|_| "Enter the expiry date as YYYY-MM-DD")?;
142        let time = NaiveTime::parse_from_str(self.expiry_time.trim(), "%H:%M")
143            .map_err(|_| "Enter the expiry time as HH:MM")?;
144        // Unchanged fields retain the saved seconds and repeated-hour occurrence. A saved
145        // expiry whose local time is past the representable range matches no entry.
146        let saved = self.alert.expires.filter(|saved| {
147            let offset = saved.with_timezone(&self.timezone).offset().fix();
148            saved
149                .naive_utc()
150                .checked_add_offset(offset)
151                .is_some_and(|local| {
152                    local.date() == date
153                        && local.hour() == time.hour()
154                        && local.minute() == time.minute()
155                })
156        });
157        let expiry = if let Some(saved) = saved {
158            saved
159        } else {
160            // A newly entered time repeated when clocks go back uses its first occurrence.
161            self.timezone
162                .from_local_datetime(&date.and_time(time))
163                .earliest()
164                .ok_or("The expiry time is skipped when clocks go forward")?
165                .with_timezone(&Utc)
166        };
167        if expiry <= now {
168            return Err("Choose an expiry after the current time");
169        }
170        Ok(Some(expiry))
171    }
172
173    /// Fill the schedule fields with `schedule`, or clear them.
174    fn set_schedule_fields(&mut self, schedule: Option<&NotificationSchedule>) {
175        self.scheduled = schedule.is_some();
176        self.schedule_zone =
177            schedule.map_or_else(|| self.timezone.name().into(), |s| s.timezone.name().into());
178        self.schedule_days = std::array::from_fn(|i| {
179            schedule.is_some_and(|s| s.days.contains(&Weekday::try_from(i as u8).unwrap()))
180        });
181        self.schedule_windows = schedule.map_or_else(Vec::new, |s| {
182            let text = |t: NaiveTime| t.format("%H:%M").to_string();
183            s.windows
184                .iter()
185                .map(|w| [text(w.start), text(w.end)])
186                .collect()
187        });
188    }
189
190    /// The staged schedule: none when notifications go out at any time, otherwise the entered
191    /// zone, weekdays and windows.
192    fn schedule(&self) -> Result<Option<NotificationSchedule>, &'static str> {
193        if !self.scheduled {
194            return Ok(None);
195        }
196        let timezone = TimezoneId::named(self.schedule_zone.trim())
197            .ok_or("Enter a known time zone, such as UTC or America/New_York")?;
198        let days: Vec<Weekday> = (0..7u8)
199            .filter(|&i| self.schedule_days[usize::from(i)])
200            .map(|i| Weekday::try_from(i).unwrap())
201            .collect();
202        let time = |text: &str| NaiveTime::parse_from_str(text.trim(), "%H:%M");
203        let windows = self
204            .schedule_windows
205            .iter()
206            .map(|[start, end]| match (time(start), time(end)) {
207                (Ok(start), Ok(end)) => Ok(NotificationWindow { start, end }),
208                _ => Err("Enter the window times as HH:MM"),
209            })
210            .collect::<Result<Vec<_>, _>>()?;
211        let schedule = NotificationSchedule {
212            timezone,
213            days,
214            windows,
215        };
216        if schedule.days.is_empty() {
217            Err("Select at least one weekday")
218        } else if schedule.windows.is_empty() {
219            Err("Add a notification window")
220        } else if !schedule.is_valid() {
221            Err("A window needs different start and end times")
222        } else {
223            Ok(Some(schedule))
224        }
225    }
226
227    /// Dismiss without saving.
228    pub fn close(&mut self) {
229        self.is_open = false;
230    }
231
232    /// Follow an external close flag.
233    pub fn sync_open_state(&mut self, should_be_open: bool) {
234        if !should_be_open {
235            self.close();
236        }
237    }
238
239    fn poll(&mut self, ctx: &Context) {
240        let repaint = ctx.clone();
241        self.inbox.set_waker(move || repaint.request_repaint());
242        for (key, answer) in self.inbox.drain().answers {
243            self.pending = false;
244            if key == 1 {
245                let result = *answer
246                    .downcast::<Result<Option<String>, FeedError>>()
247                    .expect("preset load");
248                match result {
249                    Ok(document) => {
250                        let result = document
251                            .map(|doc| serde_json::from_str::<PresetDocument>(&doc))
252                            .transpose();
253                        match result {
254                            Ok(doc) if doc.as_ref().is_none_or(|doc| doc.version == 1) => {
255                                self.presets = doc.map(|doc| doc.presets).unwrap_or_default();
256                                self.loaded = true;
257                            }
258                            Ok(_) => self.error = Some("Unsupported alert preset version".into()),
259                            Err(error) => self.error = Some(error.to_string()),
260                        }
261                    }
262                    Err(error) => self.error = Some(error.to_string()),
263                }
264            } else if let Err(error) = *answer
265                .downcast::<Result<(), FeedError>>()
266                .expect("preset save")
267            {
268                self.error = Some(error.to_string());
269            }
270        }
271    }
272
273    fn save_preset(&mut self) {
274        self.presets
275            .insert(self.preset_name.trim().into(), self.alert.clone());
276        let document = PresetDocument {
277            version: 1,
278            presets: self.presets.clone(),
279        };
280        match serde_json::to_string(&document) {
281            Ok(document) => {
282                if let Some(storage) = &self.storage {
283                    self.pending = true;
284                    self.error = None;
285                    storage.save(
286                        StoreKind::AlertPresets,
287                        "presets",
288                        document,
289                        self.inbox.reply(2),
290                    );
291                }
292            }
293            Err(error) => self.error = Some(error.to_string()),
294        }
295    }
296
297    /// Poll preset replies and render the modal. Submit `Create` through the host alerts service.
298    pub fn show(&mut self, ctx: &Context) -> AlertDialogAction {
299        self.poll(ctx);
300        if !self.is_open {
301            return AlertDialogAction::None;
302        }
303        let mut action = AlertDialogAction::None;
304        let title = if self.alert.id.is_some() {
305            "Edit Alert"
306        } else {
307            "Create Alert"
308        };
309        let mut open = true;
310        Modal::new("create_alert", "")
311            .width(self.config.width + 32.0)
312            .height(self.config.height + 130.0)
313            .show(ctx, &mut open, |ui| {
314                let translated_label = l10n::text(ui.ctx(), title);
315                ui.response().widget_info(|| {
316                    egui::WidgetInfo::labeled(egui::Role::Dialog, true, &translated_label)
317                });
318                ui.ctx()
319                    .accesskit_node_builder(ui.response().id, |node| node.set_modal());
320                if dialog_header(ui, title) {
321                    self.close();
322                    action = AlertDialogAction::Cancel;
323                }
324                ui.separator();
325                let mut expiry = Ok(None);
326                let mut schedule = Ok(None);
327                let mut valid = false;
328                egui::ScrollArea::vertical()
329                    .max_height(
330                        (ui.available_height() - 65.0)
331                            .min(self.config.height)
332                            .max(0.0),
333                    )
334                    .show(ui, |ui| {
335                        self.form(ui);
336                        expiry = self.expiry(Utc::now());
337                        schedule = self.schedule();
338                        valid = self.alert.is_valid() && expiry.is_ok() && schedule.is_ok();
339                        if !self.alert.is_valid() {
340                            status_message(
341                                ui,
342                                MessageKind::Error,
343                                "Check the subject, levels and lookback",
344                            );
345                        }
346                        if let Err(error) = expiry {
347                            status_message(ui, MessageKind::Error, error);
348                        }
349                        if let Err(error) = schedule {
350                            status_message(ui, MessageKind::Error, error);
351                        }
352                        if let Some(error) = &self.error {
353                            status_message(ui, MessageKind::Error, error);
354                        }
355                    });
356                let (save, cancel) = DialogFooter::new("Save alert")
357                    .secondary("Cancel")
358                    .primary_enabled(valid)
359                    .show(ui);
360                if cancel {
361                    self.close();
362                    action = AlertDialogAction::Cancel;
363                }
364                if save {
365                    self.alert.expires = expiry.unwrap();
366                    self.alert.schedule = schedule.unwrap().map(Box::new);
367                    action = AlertDialogAction::Create(Box::new(self.alert.clone()));
368                    self.close();
369                }
370            });
371        if !open {
372            self.close();
373            action = AlertDialogAction::Cancel;
374        }
375        action
376    }
377
378    fn form(&mut self, ui: &mut Ui) {
379        let time_label = l10n::format_text(
380            ui.ctx(),
381            "Expiry time ({0})",
382            &[("0", self.timezone.name().to_owned())],
383        );
384        let label_width = [l10n::text(ui.ctx(), "Expiry date"), time_label.clone()]
385            .into_iter()
386            .map(|label| {
387                ui.painter()
388                    .layout_no_wrap(
389                        label,
390                        egui::TextStyle::Body.resolve(ui.style()),
391                        ui.visuals().text_color(),
392                    )
393                    .size()
394                    .x
395            })
396            .fold(0.0, f32::max);
397        ui.spacing_mut().text_edit_width =
398            (ui.available_width() - label_width - ui.spacing().item_spacing.x).max(0.0);
399        FormGrid::new("alert_details").show(ui, |ui| {
400            ui.label(l10n::text(ui.ctx(), "Name"));
401            ui.text_edit_singleline(&mut self.alert.name)
402                .accessible_name(l10n::text(ui.ctx(), "Name"));
403            ui.end_row();
404            ui.label(l10n::text(ui.ctx(), "Symbol"));
405            ui.text_edit_singleline(&mut self.alert.symbol)
406                .accessible_name(l10n::text(ui.ctx(), "Symbol"));
407            ui.end_row();
408            ui.label(l10n::text(ui.ctx(), "Watchlist"));
409            egui::ComboBox::from_id_salt("alert_watchlist")
410                .width(ui.spacing().text_edit_width)
411                .truncate()
412                .selected_text(l10n::text(
413                    ui.ctx(),
414                    self.alert
415                        .watchlist
416                        .as_ref()
417                        .and_then(|id| {
418                            self.watchlists
419                                .iter()
420                                .find(|(key, _)| key == id)
421                                .map(|(_, name)| name.as_str())
422                        })
423                        .unwrap_or(self.alert.watchlist.as_deref().unwrap_or("One symbol")),
424                ))
425                .show_ui(ui, |ui| {
426                    ui.selectable_value(
427                        &mut self.alert.watchlist,
428                        None,
429                        l10n::text(ui.ctx(), "One symbol"),
430                    );
431                    for (id, name) in &self.watchlists {
432                        ui.selectable_value(
433                            &mut self.alert.watchlist,
434                            Some(id.clone()),
435                            l10n::text(ui.ctx(), name),
436                        );
437                    }
438                })
439                .response
440                .accessible_name(l10n::text(ui.ctx(), "Watchlist"));
441            ui.end_row();
442            ui.label(l10n::text(ui.ctx(), "Frequency"));
443            egui::ComboBox::from_id_salt("alert_frequency")
444                .selected_text(l10n::text(ui.ctx(), self.alert.frequency.to_string()))
445                .show_ui(ui, |ui| {
446                    for (value, label) in [
447                        (AlertFrequency::Once, "Only once"),
448                        (AlertFrequency::EveryTime, "Every time"),
449                        (AlertFrequency::OncePerBar, "Once per bar"),
450                        (AlertFrequency::OncePerBarClose, "Once per bar close"),
451                        (AlertFrequency::OncePerMinute, "Once per minute"),
452                    ] {
453                        if matches!(self.alert.condition, AlertCondition::StrategyFills(_))
454                            && !matches!(value, AlertFrequency::Once | AlertFrequency::EveryTime)
455                        {
456                            continue;
457                        }
458                        ui.selectable_value(
459                            &mut self.alert.frequency,
460                            value,
461                            l10n::text(ui.ctx(), label),
462                        );
463                    }
464                })
465                .response
466                .accessible_name(l10n::text(ui.ctx(), "Frequency"));
467            ui.end_row();
468            // The widest expiry label's width keeps the fields in place when No expiry toggles.
469            ui.scope(|ui| {
470                ui.set_min_width(label_width);
471                ui.label(l10n::text(ui.ctx(), "Expiry"));
472            });
473            let mut no_expiry = !self.expires;
474            if ui
475                .checkbox(&mut no_expiry, l10n::text(ui.ctx(), "No expiry"))
476                .changed()
477            {
478                self.expires = !no_expiry;
479                if self.expires && self.expiry_date.is_empty() {
480                    // Start a month ahead, to the minute, so an expiry needs no typing.
481                    let now = Utc::now();
482                    self.set_expiry_fields(now.checked_add_months(Months::new(1)).or(Some(now)));
483                }
484            }
485            ui.end_row();
486            if self.expires {
487                ui.label(l10n::text(ui.ctx(), "Expiry date"));
488                ui.add(
489                    egui::TextEdit::singleline(&mut self.expiry_date)
490                        .hint_text(l10n::text(ui.ctx(), "YYYY-MM-DD")),
491                )
492                .accessible_name(l10n::text(ui.ctx(), "Expiry date"));
493                ui.end_row();
494                ui.label(&time_label);
495                ui.add(
496                    egui::TextEdit::singleline(&mut self.expiry_time)
497                        .hint_text(l10n::text(ui.ctx(), "HH:MM")),
498                )
499                .accessible_name(time_label);
500                ui.end_row();
501            }
502            ui.label(l10n::text(ui.ctx(), "Notifications"));
503            let mut any_time = !self.scheduled;
504            if ui
505                .checkbox(&mut any_time, l10n::text(ui.ctx(), "Any time"))
506                .changed()
507            {
508                self.scheduled = !any_time;
509            }
510            ui.end_row();
511            if self.scheduled {
512                ui.label(l10n::text(ui.ctx(), "Time zone"));
513                ui.text_edit_singleline(&mut self.schedule_zone)
514                    .accessible_name(l10n::text(ui.ctx(), "Time zone"));
515                ui.end_row();
516                ui.label(l10n::text(ui.ctx(), "Days"));
517                ui.horizontal_wrapped(|ui| {
518                    for (selected, day) in self.schedule_days.iter_mut().zip(DAYS) {
519                        ui.toggle_value(selected, l10n::text(ui.ctx(), day));
520                    }
521                });
522                ui.end_row();
523                ui.label(l10n::text(ui.ctx(), "Windows"));
524                ui.vertical(|ui| {
525                    let mut remove = None;
526                    for (index, [start, end]) in self.schedule_windows.iter_mut().enumerate() {
527                        ui.horizontal(|ui| {
528                            ui.add(egui::TextEdit::singleline(start).desired_width(48.0))
529                                .accessible_name(l10n::text(ui.ctx(), "Window start"));
530                            ui.label("-");
531                            ui.add(egui::TextEdit::singleline(end).desired_width(48.0))
532                                .accessible_name(l10n::text(ui.ctx(), "Window end"));
533                            if ui.button(l10n::text(ui.ctx(), "Remove")).clicked() {
534                                remove = Some(index);
535                            }
536                        });
537                    }
538                    if let Some(index) = remove {
539                        self.schedule_windows.remove(index);
540                    }
541                    if ui.button(l10n::text(ui.ctx(), "Add window")).clicked() {
542                        self.schedule_windows.push(["09:00".into(), "17:00".into()]);
543                    }
544                });
545                ui.end_row();
546            }
547            ui.label(l10n::text(ui.ctx(), "Message"));
548            ui.text_edit_multiline(&mut self.alert.message)
549                .accessible_name(l10n::text(ui.ctx(), "Message"));
550            ui.end_row();
551        });
552        ui.separator();
553        if !matches!(self.alert.condition, AlertCondition::StrategyFills(_)) {
554            ui.label(l10n::text(
555                ui.ctx(),
556                "All conditions must hold on the same bar",
557            ));
558        }
559        ui.push_id("first", |ui| {
560            rule_form(
561                ui,
562                &mut self.alert.source,
563                &mut self.alert.condition,
564                &self.sources,
565            )
566        });
567        let mut remove = None;
568        for (index, rule) in self.alert.additional_conditions.iter_mut().enumerate() {
569            ui.push_id(index, |ui| {
570                ui.separator();
571                rule_form(ui, &mut rule.source, &mut rule.condition, &self.sources);
572                if ui
573                    .button(l10n::text(ui.ctx(), "Remove condition"))
574                    .clicked()
575                {
576                    remove = Some(index);
577                }
578            });
579        }
580        if let Some(index) = remove {
581            self.alert.additional_conditions.remove(index);
582        }
583        if !matches!(self.alert.condition, AlertCondition::StrategyFills(_))
584            && ui.button(l10n::text(ui.ctx(), "Add condition")).clicked()
585        {
586            self.alert.additional_conditions.push(AlertRule {
587                source: self.alert.source.clone(),
588                condition: self.alert.condition.clone(),
589            });
590        }
591        if self.storage.is_some() {
592            ui.separator();
593            ui.horizontal_wrapped(|ui| {
594                ui.label(l10n::text(ui.ctx(), "Preset"));
595                ui.text_edit_singleline(&mut self.preset_name)
596                    .accessible_name(l10n::text(ui.ctx(), "Preset"));
597                if ui
598                    .add_enabled(
599                        self.loaded
600                            && !self.pending
601                            && self.alert.is_valid()
602                            && !self.preset_name.trim().is_empty(),
603                        egui::Button::new(l10n::text(ui.ctx(), "Save preset")),
604                    )
605                    .clicked()
606                    && let (Ok(expiry), Ok(schedule)) = (self.expiry(Utc::now()), self.schedule())
607                {
608                    self.alert.expires = expiry;
609                    self.alert.schedule = schedule.map(Box::new);
610                    self.save_preset();
611                }
612            });
613            let mut apply = None;
614            egui::ComboBox::from_id_salt("alert_presets")
615                .selected_text(l10n::text(ui.ctx(), "Apply preset"))
616                .show_ui(ui, |ui| {
617                    for (name, alert) in &self.presets {
618                        if ui
619                            .selectable_label(false, l10n::text(ui.ctx(), name))
620                            .clicked()
621                        {
622                            apply = Some(alert.clone());
623                        }
624                    }
625                })
626                .response
627                .accessible_name(l10n::text(ui.ctx(), "Apply preset"));
628            if let Some(mut preset) = apply {
629                preset.id.clone_from(&self.alert.id);
630                preset.status = self.alert.status;
631                self.edit(preset);
632            }
633        }
634    }
635}
636
637const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
638
639#[derive(serde::Serialize, serde::Deserialize)]
640struct PresetDocument {
641    version: u32,
642    presets: BTreeMap<String, Alert>,
643}
644
645fn source_picker(
646    ui: &mut Ui,
647    id: &str,
648    source: &mut AlertSource,
649    sources: &[(AlertSource, String)],
650) {
651    let label = sources
652        .iter()
653        .find(|(value, _)| value == source)
654        .map(|(_, name)| name.clone())
655        .unwrap_or_else(|| format!("{source:?}"));
656    egui::ComboBox::from_id_salt(id)
657        .width(ui.available_width().min(200.0))
658        .truncate()
659        .selected_text(l10n::text(ui.ctx(), label))
660        .show_ui(ui, |ui| {
661            if !id.starts_with("boundary_") {
662                ui.selectable_value(source, AlertSource::Price, l10n::text(ui.ctx(), "Price"));
663                ui.selectable_value(source, AlertSource::Volume, l10n::text(ui.ctx(), "Volume"));
664            }
665            for (value, name) in sources {
666                ui.selectable_value(source, value.clone(), l10n::text(ui.ctx(), name));
667            }
668        })
669        .response
670        .accessible_name(l10n::text(
671            ui.ctx(),
672            match id {
673                "other" => "Comparison source",
674                "boundary_a" => "First boundary",
675                "boundary_b" => "Second boundary",
676                _ => "Source",
677            },
678        ));
679}
680
681fn drawing_id(source: &AlertSource) -> Option<&str> {
682    match source {
683        AlertSource::Drawing(id) => Some(id.split('#').next().unwrap_or(id)),
684        _ => None,
685    }
686}
687
688/// Seed a channel from the selected drawing, retaining an existing pair verbatim.
689fn drawing_channel(
690    source: &AlertSource,
691    condition: &AlertCondition,
692    sources: &[(AlertSource, String)],
693) -> Option<AlertCondition> {
694    if let AlertCondition::Channel { .. } = condition {
695        return Some(condition.clone());
696    }
697    let selected = match condition {
698        AlertCondition::Compare { other, .. } if drawing_id(other).is_some() => Some(other),
699        _ if drawing_id(source).is_some() => Some(source),
700        _ => sources
701            .iter()
702            .find(|(s, _)| {
703                drawing_id(s).is_some()
704                    && sources
705                        .iter()
706                        .filter(|(other, _)| drawing_id(other) == drawing_id(s))
707                        .count()
708                        >= 2
709            })
710            .map(|(s, _)| s),
711    }?;
712    let mut levels = sources
713        .iter()
714        .filter(|(s, _)| drawing_id(s) == drawing_id(selected))
715        .rev();
716    let b = levels.next()?.0.clone();
717    let a = levels.next()?.0.clone();
718    Some(AlertCondition::Channel {
719        boundaries: [a, b],
720        comparison: AlertChannelComparison::Entering,
721    })
722}
723
724pub(crate) fn rule_form(
725    ui: &mut Ui,
726    source: &mut AlertSource,
727    condition: &mut AlertCondition,
728    sources: &[(AlertSource, String)],
729) {
730    ui.add_enabled_ui(
731        !matches!(
732            condition,
733            AlertCondition::VolumeAboveAverage { .. } | AlertCondition::StrategyFills(_)
734        ),
735        |ui| source_picker(ui, "source", source, sources),
736    );
737    let level = condition.level().unwrap_or(0.0);
738    let channel = drawing_channel(source, condition, sources);
739    let choices = [
740        (AlertCondition::Crossing(level), "Crossing"),
741        (AlertCondition::CrossingUp(level), "Crossing up"),
742        (AlertCondition::CrossingDown(level), "Crossing down"),
743        (AlertCondition::Above(level), "Greater than"),
744        (AlertCondition::Below(level), "Less than"),
745        (
746            AlertCondition::EnteringChannel {
747                lower: level,
748                upper: level + 1.0,
749            },
750            "Entering channel",
751        ),
752        (
753            AlertCondition::ExitingChannel {
754                lower: level,
755                upper: level + 1.0,
756            },
757            "Exiting channel",
758        ),
759        (
760            AlertCondition::InsideChannel {
761                lower: level,
762                upper: level + 1.0,
763            },
764            "Inside channel",
765        ),
766        (
767            AlertCondition::OutsideChannel {
768                lower: level,
769                upper: level + 1.0,
770            },
771            "Outside channel",
772        ),
773        (
774            AlertCondition::MovingBy {
775                value: 1.0,
776                bars: 1,
777            },
778            "Moving by value",
779        ),
780        (
781            AlertCondition::MovingByPercent {
782                percent: 1.0,
783                bars: 1,
784            },
785            "Moving by percent",
786        ),
787        (
788            AlertCondition::Compare {
789                other: AlertSource::Price,
790                comparison: AlertComparison::Crossing,
791            },
792            "Compare outputs",
793        ),
794        (
795            AlertCondition::VolumeAboveAverage {
796                multiplier: 2.0,
797                bars: 20,
798            },
799            "Volume above average",
800        ),
801    ]
802    .into_iter()
803    .chain(channel.map(|c| (c, "Drawing channel")))
804    .collect::<Vec<_>>();
805    let selected = choices
806        .iter()
807        .find(|(c, _)| std::mem::discriminant(c) == std::mem::discriminant(condition))
808        .map(|(_, name)| *name)
809        .unwrap_or(if matches!(condition, AlertCondition::StrategyFills(_)) {
810            "Strategy fills"
811        } else {
812            "Condition"
813        });
814    egui::ComboBox::from_id_salt("condition")
815        .selected_text(l10n::text(ui.ctx(), selected))
816        .show_ui(ui, |ui| {
817            for (value, label) in choices {
818                if ui
819                    .selectable_label(label == selected, l10n::text(ui.ctx(), label))
820                    .clicked()
821                    && label != selected
822                {
823                    if matches!(value, AlertCondition::VolumeAboveAverage { .. }) {
824                        *source = AlertSource::Volume;
825                    }
826                    *condition = value;
827                }
828            }
829        })
830        .response
831        .accessible_name(l10n::text(ui.ctx(), "Condition"));
832    ui.horizontal_wrapped(|ui| match condition {
833        AlertCondition::StrategyFills(run) => {
834            ui.text_edit_singleline(run)
835                .accessible_name(l10n::text(ui.ctx(), "Strategy run"));
836        }
837        AlertCondition::Crossing(v)
838        | AlertCondition::CrossingUp(v)
839        | AlertCondition::CrossingDown(v)
840        | AlertCondition::Above(v)
841        | AlertCondition::Below(v) => {
842            ui.label(l10n::text(ui.ctx(), "Level"));
843            ui.add(egui::DragValue::new(v).speed(0.01))
844                .accessible_name(l10n::text(ui.ctx(), "Level"));
845        }
846        AlertCondition::EnteringChannel { lower, upper }
847        | AlertCondition::ExitingChannel { lower, upper }
848        | AlertCondition::InsideChannel { lower, upper }
849        | AlertCondition::OutsideChannel { lower, upper } => {
850            ui.label(l10n::text(ui.ctx(), "Lower / upper"));
851            ui.add(egui::DragValue::new(lower))
852                .accessible_name(l10n::text(ui.ctx(), "Lower"));
853            ui.add(egui::DragValue::new(upper))
854                .accessible_name(l10n::text(ui.ctx(), "Upper"));
855        }
856        AlertCondition::MovingBy { value, bars }
857        | AlertCondition::MovingByPercent {
858            percent: value,
859            bars,
860        } => {
861            ui.label(l10n::text(ui.ctx(), "Change / bars"));
862            ui.add(egui::DragValue::new(value))
863                .accessible_name(l10n::text(ui.ctx(), "Change"));
864            ui.add(egui::DragValue::new(bars).range(1..=u32::MAX))
865                .accessible_name(l10n::text(ui.ctx(), "Bars"));
866        }
867        AlertCondition::VolumeAboveAverage { multiplier, bars } => {
868            ui.label(l10n::text(ui.ctx(), "Multiplier / preceding bars"));
869            ui.add(egui::DragValue::new(multiplier).speed(0.1))
870                .accessible_name(l10n::text(ui.ctx(), "Multiplier"));
871            ui.add(egui::DragValue::new(bars).range(1..=u32::MAX))
872                .accessible_name(l10n::text(ui.ctx(), "Bars"));
873        }
874        AlertCondition::Channel {
875            boundaries,
876            comparison,
877        } => {
878            let drawings: Vec<_> = sources
879                .iter()
880                .filter(|(s, _)| drawing_id(s).is_some())
881                .cloned()
882                .collect();
883            ui.label(l10n::text(ui.ctx(), "Drawing boundaries"));
884            source_picker(ui, "boundary_a", &mut boundaries[0], &drawings);
885            source_picker(ui, "boundary_b", &mut boundaries[1], &drawings);
886            egui::ComboBox::from_id_salt("channel_comparison")
887                .selected_text(l10n::text(ui.ctx(), comparison.to_string()))
888                .show_ui(ui, |ui| {
889                    for value in [
890                        AlertChannelComparison::Entering,
891                        AlertChannelComparison::Exiting,
892                        AlertChannelComparison::Inside,
893                        AlertChannelComparison::Outside,
894                    ] {
895                        ui.selectable_value(
896                            comparison,
897                            value,
898                            l10n::text(ui.ctx(), value.to_string()),
899                        );
900                    }
901                })
902                .response
903                .accessible_name(l10n::text(ui.ctx(), "Channel condition"));
904        }
905        AlertCondition::Compare { other, comparison } => {
906            source_picker(ui, "other", other, sources);
907            egui::ComboBox::from_id_salt("comparison")
908                .selected_text(l10n::text(ui.ctx(), comparison.to_string()))
909                .show_ui(ui, |ui| {
910                    for value in [
911                        AlertComparison::Crossing,
912                        AlertComparison::CrossingUp,
913                        AlertComparison::CrossingDown,
914                        AlertComparison::Above,
915                        AlertComparison::Below,
916                    ] {
917                        ui.selectable_value(
918                            comparison,
919                            value,
920                            l10n::text(ui.ctx(), value.to_string()),
921                        );
922                    }
923                })
924                .response
925                .accessible_name(l10n::text(ui.ctx(), "Comparison"));
926        }
927    });
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use crate::data::Reply;
934    use std::sync::Mutex;
935
936    #[derive(Default)]
937    struct Store(Mutex<Option<String>>);
938    impl Storage for Store {
939        fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
940            reply.send(Ok(Vec::new()));
941        }
942        fn load(&self, _: StoreKind, _: &str, reply: Reply<Option<String>>) {
943            reply.send(Ok(self.0.lock().unwrap().clone()));
944        }
945        fn save(&self, _: StoreKind, _: &str, doc: String, reply: Reply<()>) {
946            *self.0.lock().unwrap() = Some(doc);
947            reply.send(Ok(()));
948        }
949        fn delete(&self, _: StoreKind, _: &str, reply: Reply<()>) {
950            reply.send(Ok(()));
951        }
952    }
953
954    #[test]
955    fn drawing_channel_keeps_selected_boundaries_through_edits() {
956        use egui_kittest::{Harness, kittest::Queryable};
957        for dark in [false, true] {
958            for width in [320.0, 960.0] {
959                let drawing = |id: &str| AlertSource::Drawing(id.into());
960                let sources = vec![
961                    (drawing("a"), "Other line".into()),
962                    (drawing("a#1"), "Other boundary".into()),
963                    (drawing("b%23saved"), "Median".into()),
964                    (drawing("b%23saved#1"), "Upper".into()),
965                    (drawing("b%23saved#2"), "Lower".into()),
966                ];
967                let single = AlertCondition::Compare {
968                    other: drawing("b%23saved"),
969                    comparison: AlertComparison::Crossing,
970                };
971                let mut dialog = AlertDialog::new();
972                dialog.sources = sources;
973                dialog.edit(Alert::new("SAMPLE", single));
974                let mut h = Harness::builder()
975                    .with_size(egui::vec2(width, 900.0))
976                    .build_ui_state(
977                        |ui, dialog: &mut AlertDialog| {
978                            ui.ctx().set_visuals(if dark {
979                                egui::Visuals::dark()
980                            } else {
981                                egui::Visuals::light()
982                            });
983                            dialog.form(ui)
984                        },
985                        dialog,
986                    );
987                h.run();
988                h.get_by_label("Condition").click();
989                h.run();
990                h.get_by_label("Drawing channel").scroll_to_me();
991                h.run();
992                h.get_by_label("Drawing channel").click();
993                h.run();
994                let expected: AlertCondition = serde_json::from_value(serde_json::json!({"Channel": {
995                    "boundaries": [{"Drawing":"b%23saved#1"},{"Drawing":"b%23saved#2"}], "comparison":"Entering"
996                }})).unwrap();
997                assert_eq!(h.state().alert.condition, expected);
998                let saved = serde_json::to_string(&h.state().alert).unwrap();
999                h.state_mut().edit(serde_json::from_str(&saved).unwrap());
1000                h.run();
1001                assert_eq!(h.state().alert.condition, expected);
1002                for label in ["First boundary", "Second boundary", "Channel condition"] {
1003                    let rect = h.get_by_label(label).rect();
1004                    assert!(
1005                        rect.left() >= 0.0 && rect.right() <= width,
1006                        "{label}: {rect:?} at {width}"
1007                    );
1008                }
1009            }
1010        }
1011    }
1012
1013    #[test]
1014    fn form_controls_fill_the_dialog_width_for_each_source() {
1015        for (source, condition) in [
1016            (AlertSource::Price, AlertCondition::Above(100.0)),
1017            (
1018                AlertSource::Study {
1019                    id: "study".into(),
1020                    output: 0,
1021                },
1022                AlertCondition::Above(100.0),
1023            ),
1024            (AlertSource::Volume, AlertCondition::Above(100.0)),
1025            (
1026                AlertSource::Price,
1027                AlertCondition::StrategyFills("run".into()),
1028            ),
1029        ] {
1030            for width in [360.0, 560.0] {
1031                let ctx = Context::default();
1032                let mut dialog = AlertDialog::new();
1033                dialog.alert = Alert::new("SAMPLE", condition.clone());
1034                dialog.alert.source = source.clone();
1035                let mut form_width = 0.0;
1036                for _ in 0..3 {
1037                    let output = ctx.run_ui(Default::default(), |ui| {
1038                        ui.set_width(width);
1039                        let form = ui.vertical(|ui| dialog.form(ui));
1040                        form_width = form.response.rect.width();
1041                    });
1042                    if matches!(condition, AlertCondition::StrategyFills(_)) {
1043                        assert!(
1044                            !output.shapes.iter().any(|shape| matches!(
1045                                &shape.shape, egui::Shape::Text(text)
1046                                    if text.galley.text() == "Add condition"
1047                            )),
1048                            "fill alerts cannot add bar conditions"
1049                        );
1050                    }
1051                    output.drop_without_applying_deltas();
1052                }
1053                assert!(
1054                    (form_width - width).abs() < 16.0,
1055                    "{source:?}: {form_width} at {width}"
1056                );
1057            }
1058        }
1059    }
1060
1061    #[test]
1062    fn alert_form_and_actions_fit_narrow_windows_in_both_themes() {
1063        for (dark, fills) in [(false, false), (true, false), (false, true), (true, true)] {
1064            let ctx = Context::default();
1065            ctx.set_visuals(if dark {
1066                egui::Visuals::dark()
1067            } else {
1068                egui::Visuals::light()
1069            });
1070            let mut dialog = AlertDialog::new();
1071            dialog.set_storage(Arc::new(Store::default()));
1072            dialog.open("SAMPLE".into(), 100.0);
1073            dialog
1074                .watchlists
1075                .push(("list".into(), "A long host watchlist name".repeat(4)));
1076            dialog.alert.watchlist = Some("list".into());
1077            dialog.alert.frequency = AlertFrequency::OncePerBarClose;
1078            dialog.sources.push((
1079                AlertSource::Drawing("7".into()),
1080                "A long drawing name".repeat(4),
1081            ));
1082            dialog.alert.additional_conditions.push(AlertRule {
1083                source: AlertSource::Volume,
1084                condition: AlertCondition::VolumeAboveAverage {
1085                    multiplier: 2.0,
1086                    bars: 20,
1087                },
1088            });
1089            dialog.alert.additional_conditions.push(AlertRule {
1090                source: AlertSource::Price,
1091                condition: AlertCondition::Compare {
1092                    other: AlertSource::Drawing("7".into()),
1093                    comparison: AlertComparison::Crossing,
1094                },
1095            });
1096            dialog.sources.push((
1097                AlertSource::Drawing("7#1".into()),
1098                "A long second boundary name".repeat(4),
1099            ));
1100            dialog.alert.additional_conditions.push(AlertRule {
1101                source: AlertSource::Price,
1102                condition: AlertCondition::Channel {
1103                    boundaries: [
1104                        AlertSource::Drawing("7".into()),
1105                        AlertSource::Drawing("7#1".into()),
1106                    ],
1107                    comparison: AlertChannelComparison::Entering,
1108                },
1109            });
1110            if fills {
1111                dialog.alert = Alert::new("SAMPLE", AlertCondition::StrategyFills("run".into()));
1112            }
1113            for size in [
1114                egui::vec2(1440.0, 900.0),
1115                egui::vec2(600.0, 360.0),
1116                egui::vec2(320.0, 450.0),
1117            ] {
1118                for invalid in [false, true] {
1119                    dialog.expires = invalid;
1120                    dialog.expiry_date = if invalid { "invalid" } else { "" }.into();
1121                    for frame in 0..3 {
1122                        let output = ctx.run_ui(
1123                            egui::RawInput {
1124                                screen_rect: Some(egui::Rect::from_min_size(
1125                                    egui::Pos2::ZERO,
1126                                    size,
1127                                )),
1128                                ..Default::default()
1129                            },
1130                            |ui| {
1131                                dialog.show(ui.ctx());
1132                            },
1133                        );
1134                        let save = output.shapes.iter().find_map(|shape| match &shape.shape {
1135                            egui::Shape::Text(text) if text.galley.text() == "Save alert" => {
1136                                Some((
1137                                    shape.clip_rect,
1138                                    egui::Rect::from_min_size(text.pos, text.galley.size()),
1139                                ))
1140                            }
1141                            _ => None,
1142                        });
1143                        let message_visible = output.shapes.iter().any(|shape| matches!(
1144                            &shape.shape, egui::Shape::Text(text) if text.galley.text() == "Message"
1145                                && shape.clip_rect.contains_rect(egui::Rect::from_min_size(text.pos, text.galley.size()))
1146                        ));
1147                        output.drop_without_applying_deltas();
1148                        if frame > 0 {
1149                            if size.y == 900.0 {
1150                                assert!(message_visible, "the default height fits the message row");
1151                            }
1152                            let (clip, button) = save.expect("save action is painted");
1153                            assert!(
1154                                clip.contains_rect(button),
1155                                "save action is clipped: {button:?}"
1156                            );
1157                        }
1158                    }
1159                    let rect = ctx
1160                        .memory(|m| m.area_rect(egui::Id::unique("create_alert")))
1161                        .unwrap();
1162                    assert!(
1163                        rect.left() >= 0.0
1164                            && rect.right() <= size.x
1165                            && rect.top() >= 0.0
1166                            && rect.bottom() <= size.y,
1167                        "dark={dark}, invalid={invalid}, size={size:?}: {rect:?}"
1168                    );
1169                }
1170            }
1171        }
1172    }
1173
1174    #[test]
1175    fn expiry_fields_resolve_in_the_entry_zone() {
1176        let at = |y, mo, d, h, mi| Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap();
1177        let check = |zone: TimezoneId, fields: Option<(&str, &str)>, expected| {
1178            let mut dialog = AlertDialog::new();
1179            dialog.timezone = zone.clone();
1180            dialog.expires = fields.is_some();
1181            (dialog.expiry_date, dialog.expiry_time) = fields
1182                .map(|(date, time)| (date.into(), time.into()))
1183                .unwrap_or_default();
1184            let expiry = dialog.expiry(at(2026, 1, 1, 0, 0));
1185            assert_eq!(expiry, expected, "{} {fields:?}", zone.name());
1186            // Editing a saved alert shows its expiry in the same fields.
1187            if let (Ok(Some(saved)), Some(fields)) = (expiry, fields) {
1188                let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1189                alert.expires = Some(saved);
1190                dialog.edit(alert);
1191                let shown = (dialog.expiry_date.as_str(), dialog.expiry_time.as_str());
1192                assert_eq!(shown, fields);
1193            }
1194        };
1195        let check_saved = |zone: TimezoneId, saved, edited: &str, expected| {
1196            let mut dialog = AlertDialog::new();
1197            dialog.timezone = zone;
1198            let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1199            alert.expires = Some(saved);
1200            dialog.edit(alert);
1201            let now = at(2026, 1, 1, 0, 0);
1202            assert_eq!(
1203                dialog.expiry(now),
1204                Ok(Some(saved)),
1205                "unchanged fields retain the saved instant"
1206            );
1207            dialog.expiry_time = edited.into();
1208            assert_eq!(
1209                dialog.expiry(now),
1210                Ok(Some(expected)),
1211                "edited fields resolve in the entry zone"
1212            );
1213        };
1214        let utc = TimezoneId::utc;
1215        check_saved(
1216            utc(),
1217            at(2026, 3, 8, 2, 30) + chrono::Duration::milliseconds(12_345),
1218            "02:31",
1219            at(2026, 3, 8, 2, 31),
1220        );
1221        let east_of_utc = |hours: i32| TimezoneId::fixed_offset(hours * 3600).unwrap();
1222        for (zone, fields, expected) in [
1223            (utc(), None, Ok(None)),
1224            (
1225                utc(),
1226                Some(("2026-03-08", "02:30")),
1227                Ok(Some(at(2026, 3, 8, 2, 30))),
1228            ),
1229            (
1230                east_of_utc(-5),
1231                Some(("2026-03-08", "02:30")),
1232                Ok(Some(at(2026, 3, 8, 7, 30))),
1233            ),
1234            (
1235                east_of_utc(9),
1236                Some(("2026-03-08", "02:30")),
1237                Ok(Some(at(2026, 3, 7, 17, 30))),
1238            ),
1239            (
1240                utc(),
1241                Some(("08/03/2026", "02:30")),
1242                Err("Enter the expiry date as YYYY-MM-DD"),
1243            ),
1244            (
1245                utc(),
1246                Some(("2026-03-08", "2:30 pm")),
1247                Err("Enter the expiry time as HH:MM"),
1248            ),
1249            (
1250                utc(),
1251                Some(("2025-12-31", "23:59")),
1252                Err("Choose an expiry after the current time"),
1253            ),
1254        ] {
1255            check(zone, fields, expected);
1256        }
1257        // A saved expiry at the end of the range has no local time on that side of UTC, so an
1258        // entered time replaces it.
1259        for (hours, saved) in [
1260            (9, DateTime::<Utc>::MAX_UTC),
1261            (-9, DateTime::<Utc>::MIN_UTC),
1262        ] {
1263            let mut dialog = AlertDialog::new();
1264            dialog.timezone = east_of_utc(hours);
1265            let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1266            alert.expires = Some(saved);
1267            dialog.edit(alert);
1268            dialog.expiry_date = "2030-01-01".into();
1269            dialog.expiry_time = "10:00".into();
1270            assert_eq!(
1271                dialog.expiry(at(2026, 1, 1, 0, 0)),
1272                Ok(Some(at(2030, 1, 1, (10 - hours) as u32, 0)))
1273            );
1274        }
1275        #[cfg(feature = "timezones")]
1276        {
1277            let new_york = || TimezoneId::named("America/New_York").unwrap();
1278            check_saved(
1279                new_york(),
1280                at(2026, 11, 1, 6, 30) + chrono::Duration::seconds(17),
1281                "01:31",
1282                at(2026, 11, 1, 5, 31),
1283            );
1284            // Clocks went from 02:00 to 03:00 on 8 March 2026, and on 1 November 2026 from
1285            // 02:00 daylight time (UTC-4) back to 01:00 standard time (UTC-5).
1286            for (fields, expected) in [
1287                (
1288                    ("2026-03-08", "02:30"),
1289                    Err("The expiry time is skipped when clocks go forward"),
1290                ),
1291                (("2026-11-01", "01:30"), Ok(Some(at(2026, 11, 1, 5, 30)))),
1292                (("2026-07-04", "11:45"), Ok(Some(at(2026, 7, 4, 15, 45)))),
1293            ] {
1294                check(new_york(), Some(fields), expected);
1295            }
1296        }
1297    }
1298
1299    #[test]
1300    fn schedule_fields_resolve_to_a_valid_schedule_or_name_the_problem() {
1301        let mut dialog = AlertDialog::new();
1302        assert_eq!(dialog.schedule(), Ok(None));
1303        dialog.scheduled = true;
1304        dialog.schedule_zone = "UTC".into();
1305        assert_eq!(dialog.schedule(), Err("Select at least one weekday"));
1306        dialog.schedule_days[4] = true;
1307        assert_eq!(dialog.schedule(), Err("Add a notification window"));
1308        dialog.schedule_windows = vec![["22:00".into(), "22:00".into()]];
1309        assert_eq!(
1310            dialog.schedule(),
1311            Err("A window needs different start and end times")
1312        );
1313        dialog.schedule_windows = vec![["22:00".into(), "2am".into()]];
1314        assert_eq!(dialog.schedule(), Err("Enter the window times as HH:MM"));
1315        dialog.schedule_windows = vec![["22:00".into(), "02:00".into()]];
1316        dialog.schedule_zone = "Mars/Olympus".into();
1317        assert!(dialog.schedule().is_err());
1318        dialog.schedule_zone = "UTC".into();
1319        let schedule = dialog.schedule().unwrap().unwrap();
1320        assert_eq!(schedule.days, [Weekday::Fri]);
1321        // Editing a saved alert shows the same fields.
1322        let mut alert = Alert::new("SAMPLE", AlertCondition::Crossing(1.0));
1323        alert.schedule = Some(Box::new(schedule.clone()));
1324        let mut other = AlertDialog::new();
1325        other.edit(alert);
1326        assert_eq!(other.schedule(), Ok(Some(schedule)));
1327    }
1328
1329    #[test]
1330    fn presets_survive_a_new_dialog_and_unknown_versions_cannot_be_overwritten() {
1331        let store = Arc::new(Store::default());
1332        let ctx = Context::default();
1333        let mut dialog = AlertDialog::new();
1334        dialog.set_storage(store.clone());
1335        dialog.poll(&ctx);
1336        dialog.open("EURUSD".into(), 1.125);
1337        dialog.alert.name = "Breakout".into();
1338        dialog.alert.frequency = AlertFrequency::OncePerBarClose;
1339        dialog.preset_name = "Close".into();
1340        dialog.save_preset();
1341        dialog.poll(&ctx);
1342        let expected = dialog.alert.clone();
1343        let mut restored = AlertDialog::new();
1344        restored.set_storage(store.clone());
1345        restored.poll(&ctx);
1346        assert_eq!(restored.presets["Close"], expected);
1347        *store.0.lock().unwrap() = Some(r#"{"version":2,"presets":{}}"#.into());
1348        restored.set_storage(store);
1349        restored.poll(&ctx);
1350        assert!(!restored.loaded);
1351        assert!(restored.error.is_some());
1352        restored.open("AAPL".into(), 123.0);
1353        assert_eq!(restored.alert.condition, AlertCondition::Crossing(123.0));
1354        assert!(restored.alert.name.is_empty());
1355    }
1356}