use chrono::Utc; use egui::{Color32, Context, RichText, TextEdit}; use std::sync::mpsc; use crate::analysis::{self, AnalysisResult}; use crate::config::Config; use crate::data::cache::CacheStore; use crate::data::{self, AnalysisInput}; use crate::ui::{analysis_panel, data_panel}; use crate::watchlist::{self, Watchlist, WatchlistEntry}; // ─── Background task message types ──────────────────────────────────────── enum BgCmd { UpdateWatchlist(Vec), RunNow, CheckUpdate, Quit, } enum UpdateCheckResult { Available(String, String), // tag, download_url UpToDate, } enum BgCheckResult { Entry { symbol: String, input: AnalysisInput, result: AnalysisResult, prev_signal: String, prev_score: f64, }, CycleDone, } // ─── App state ───────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq)] enum FetchState { Idle, Loading, Done, Error(String), } pub struct TickrApp { // Analysis ticker_input: String, current_analysis: Option<(AnalysisInput, AnalysisResult)>, fetch_state: FetchState, cache: CacheStore, config: Config, status_message: String, save_status: Option, // Async fetch channel fetch_tx: mpsc::Sender>, fetch_rx: mpsc::Receiver>, tokio_rt: tokio::runtime::Runtime, // UI state show_settings: bool, show_watchlist_panel: bool, // Watchlist watchlist: Watchlist, bg_cmd_tx: Option>, bg_result_rx: mpsc::Receiver, snapshot_batch: Vec<(String, String, AnalysisResult)>, // (ticker, company, result) — accumulated per cycle // Updater update_available: Option<(String, String)>, // (tag, download_url) update_in_progress: bool, update_result_rx: mpsc::Receiver, // Tray — use our own channels fed by set_event_handler so events are // delivered even when the window is hidden (the global receiver() only // works reliably while the winit loop is actively processing messages). #[allow(dead_code)] tray_icon: Option, tray_open_id: Option, tray_check_id: Option, tray_quit_id: Option, tray_ev_rx: mpsc::Receiver, menu_ev_rx: mpsc::Receiver, window_visible: bool, hide_to_tray: bool, should_quit: bool, } impl TickrApp { pub fn new( cc: &eframe::CreationContext<'_>, config: Config, icon_rgba: Vec, icon_size: u32, start_visible: bool, ) -> Self { let ctx = &cc.egui_ctx; let mut visuals = egui::Visuals::dark(); visuals.override_text_color = Some(Color32::from_rgb(220, 220, 220)); ctx.set_visuals(visuals); let (fetch_tx, fetch_rx) = mpsc::channel(); let (bg_result_tx, bg_result_rx) = mpsc::channel::(); let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let watchlist = watchlist::load(); // Set up tray let (tray_icon, tray_open_id, tray_check_id, tray_quit_id) = setup_tray(icon_rgba.clone(), icon_size, watchlist.tickers.len()); // Route tray events through our own sync channels + wake egui immediately. // SyncSender is Send + Sync, satisfying the 'static closure requirement. // (The global receiver() channel is NOT used — set_event_handler replaces it.) let (tray_ev_tx, tray_ev_rx) = mpsc::sync_channel::(64); let (menu_ev_tx, menu_ev_rx) = mpsc::sync_channel::(64); let ctx_tray = cc.egui_ctx.clone(); tray_icon::TrayIconEvent::set_event_handler(Some(move |e| { let _ = tray_ev_tx.send(e); ctx_tray.request_repaint(); })); let ctx_menu = cc.egui_ctx.clone(); tray_icon::menu::MenuEvent::set_event_handler(Some(move |e| { let _ = menu_ev_tx.send(e); ctx_menu.request_repaint(); })); // Spawn background check task let (bg_cmd_tx, bg_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let (update_result_tx, update_result_rx) = mpsc::channel::(); let initial_entries = watchlist.tickers.clone(); let bg_config = config.clone(); rt.spawn(background_task(bg_cmd_rx, bg_result_tx, update_result_tx, initial_entries, bg_config)); Self { ticker_input: String::new(), current_analysis: None, fetch_state: FetchState::Idle, cache: CacheStore::new(config.cache.ttl_minutes), status_message: "Ready".to_string(), save_status: None, fetch_tx, fetch_rx, tokio_rt: rt, show_settings: false, show_watchlist_panel: false, watchlist, bg_cmd_tx: Some(bg_cmd_tx), bg_result_rx, snapshot_batch: Vec::new(), update_available: None, update_in_progress: false, update_result_rx, tray_icon, tray_open_id, tray_check_id, tray_quit_id, tray_ev_rx, menu_ev_rx, window_visible: start_visible, hide_to_tray: false, should_quit: false, config, } } // ─── Fetch ──────────────────────────────────────────────────────────── fn trigger_fetch(&mut self, symbol: &str, bypass_cache: bool) { let sym = symbol.trim().to_uppercase(); if sym.is_empty() { return; } if !bypass_cache { if let Some(cached) = self.cache.get(&sym) { let age = self.cache.age_minutes(&sym).unwrap_or(0); let result = analysis::analyze(cached, &self.config); self.current_analysis = Some((cached.clone(), result)); self.fetch_state = FetchState::Done; self.status_message = format!("Using cached data (fetched {age} min ago)"); return; } } self.fetch_state = FetchState::Loading; self.status_message = format!("Fetching data for {sym}…"); let tx = self.fetch_tx.clone(); self.tokio_rt.spawn(async move { let result = data::fetch::fetch_ticker(&sym).await; let _ = tx.send(result.map_err(|e| e.to_string())); }); } fn poll_fetch_result(&mut self) { if let Ok(result) = self.fetch_rx.try_recv() { match result { Ok(input) => { let sym = input.symbol().to_string(); self.cache.insert(input.clone()); let analysis = analysis::analyze(&input, &self.config); self.current_analysis = Some((input, analysis)); self.fetch_state = FetchState::Done; self.status_message = format!("Analysis complete for {sym}"); } Err(e) => { let sym = self.ticker_input.trim().to_uppercase(); if let Some(stale) = self.cache.get_stale(&sym) { let result = analysis::analyze(stale, &self.config); self.current_analysis = Some((stale.clone(), result)); self.fetch_state = FetchState::Error(format!("Network error — showing stale data")); self.status_message = "Network error — using stale cache".to_string(); } else { self.fetch_state = FetchState::Error(e.clone()); self.status_message = format!("Error: {e}"); } } } } } // ─── Background check ───────────────────────────────────────────────── fn poll_bg_results(&mut self) { while let Ok(res) = self.bg_result_rx.try_recv() { match res { BgCheckResult::CycleDone => { self.send_watchlist_refresh_batch(); } BgCheckResult::Entry { symbol, input, result, prev_signal, prev_score } => { let new_signal = result.decision.label().to_string(); let new_score = result.final_score; let signal_changed = new_signal != prev_signal; let score_drift = (new_score - prev_score).abs(); // Update cache with fresh data self.cache.insert(input.clone()); // Update watchlist entry self.watchlist.update_entry(&symbol, &new_signal, new_score); let _ = watchlist::save(&self.watchlist); // Resolve company name (needed for both Discord and snapshot) let company = match &input { AnalysisInput::Equity(d) => { d.company_name.clone().unwrap_or_else(|| symbol.clone()) } AnalysisInput::Etf(d) => { d.fund_name.clone().unwrap_or_else(|| symbol.clone()) } }; // Accumulate for end-of-cycle snapshot (fires unconditionally when CycleDone arrives) self.snapshot_batch.push((symbol.clone(), company.clone(), result.clone())); // Existing per-ticker notifications (gated by user flags) let url = self.config.discord.webhook_url.clone(); if !url.is_empty() { if signal_changed && self.config.discord.notify_on_change { let url2 = url.clone(); let sym = symbol.clone(); let prev = prev_signal.clone(); let new_s = new_signal.clone(); let analysis = result.clone(); let co = company.clone(); self.tokio_rt.spawn(async move { if let Err(e) = crate::discord::send_signal_embed(&url2, &sym, &co, &prev, &new_s, &analysis) .await { eprintln!("Discord error: {e}"); } }); } if !signal_changed && score_drift >= 0.2 && self.config.discord.notify_on_drift { let sym = symbol.clone(); let old_s = prev_score; let analysis = result.clone(); let sig_copy = new_signal.clone(); self.tokio_rt.spawn(async move { if let Err(e) = crate::discord::send_drift_embed( &url, &sym, old_s, new_score, &sig_copy, &analysis, ) .await { eprintln!("Discord error: {e}"); } }); } } // If currently displayed ticker was refreshed, update the panel if self.ticker_input.trim().to_uppercase() == symbol.to_uppercase() { let a = analysis::analyze(&input, &self.config); self.current_analysis = Some((input, a)); } self.status_message = format!("Watchlist: {} updated ({})", symbol, new_signal); } } } } fn send_watchlist_refresh_batch(&mut self) { if self.config.discord.webhook_url.is_empty() || self.snapshot_batch.is_empty() { self.snapshot_batch.clear(); return; } let url = self.config.discord.webhook_url.clone(); let batch = std::mem::take(&mut self.snapshot_batch); self.tokio_rt.spawn(async move { // Split into groups of ≤10 (Discord's embed-per-message limit) let chunks: Vec> = batch.chunks(10).map(|c| c.to_vec()).collect(); for (i, chunk) in chunks.into_iter().enumerate() { if i > 0 { // Rate-limit protection: 30 s between groups tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; } if let Err(e) = crate::discord::send_watchlist_snapshot(&url, &chunk).await { eprintln!("Discord snapshot error: {e}"); } } }); } fn send_watchlist_to_bg(&self) { if let Some(ref tx) = self.bg_cmd_tx { let _ = tx.send(BgCmd::UpdateWatchlist(self.watchlist.tickers.clone())); } } fn trigger_bg_check_now(&self) { if let Some(ref tx) = self.bg_cmd_tx { let _ = tx.send(BgCmd::RunNow); } } fn trigger_update_check(&self) { if let Some(ref tx) = self.bg_cmd_tx { let _ = tx.send(BgCmd::CheckUpdate); } } fn poll_update_results(&mut self) { while let Ok(result) = self.update_result_rx.try_recv() { match result { UpdateCheckResult::Available(tag, url) => { if self.config.updater.auto_update { self.update_in_progress = true; let url2 = url.clone(); self.tokio_rt.spawn(async move { if let Err(e) = crate::updater::download_and_swap(&url2).await { eprintln!("Auto-update failed: {e}"); } }); } else { self.update_available = Some((tag.clone(), url)); self.status_message = format!("Update available: {tag}"); } } UpdateCheckResult::UpToDate => { if self.update_available.is_none() { self.status_message = format!("Tickr v{} — Up to date", crate::VERSION); } } } } } fn update_tray_tooltip(&self) { if let Some(ref tray) = self.tray_icon { let n = self.watchlist.tickers.len(); let tip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" }); let _ = tray.set_tooltip(Some(tip)); } } // ─── Watchlist helpers ──────────────────────────────────────────────── fn add_to_watchlist(&mut self) { let Some((input, result)) = &self.current_analysis else { return; }; let sym = input.symbol().to_uppercase(); if self.watchlist.contains(&sym) { return; } let company = match input { crate::data::AnalysisInput::Equity(d) => { d.company_name.clone().unwrap_or_else(|| sym.clone()) } crate::data::AnalysisInput::Etf(d) => { d.fund_name.clone().unwrap_or_else(|| sym.clone()) } }; let entry = WatchlistEntry { symbol: sym.clone(), last_signal: result.decision.label().to_string(), last_score: result.final_score, last_checked: Utc::now(), }; self.watchlist.add(entry); let _ = watchlist::save(&self.watchlist); self.send_watchlist_to_bg(); self.update_tray_tooltip(); if !self.config.discord.webhook_url.is_empty() { let url = self.config.discord.webhook_url.clone(); let analysis = result.clone(); self.tokio_rt.spawn(async move { if let Err(e) = crate::discord::send_added_to_watchlist_embed(&url, &sym, &company, &analysis) .await { eprintln!("Discord error: {e}"); } }); } } fn remove_from_watchlist(&mut self, symbol: &str) { self.watchlist.remove(symbol); let _ = watchlist::save(&self.watchlist); self.send_watchlist_to_bg(); self.update_tray_tooltip(); } fn current_ticker_watched(&self) -> bool { let sym = self.ticker_input.trim().to_uppercase(); !sym.is_empty() && self.watchlist.contains(&sym) } fn can_watch(&self) -> bool { let sym = self.ticker_input.trim().to_uppercase(); self.current_analysis .as_ref() .map_or(false, |(input, _)| input.symbol().to_uppercase() == sym) } // ─── Window / tray helpers ──────────────────────────────────────────── fn show_window(&mut self, ctx: &Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); self.window_visible = true; } // ─── Save report ────────────────────────────────────────────────────── fn save_report(&mut self) { let Some((input, result)) = &self.current_analysis else { return; }; let sym = input.symbol(); let date = Utc::now().format("%Y-%m-%d"); let ext = &self.config.output.save_format; let filename = format!("{sym}_{date}.{ext}"); let save_dir = if self.config.output.save_directory.is_empty() { dirs::desktop_dir().unwrap_or_else(|| std::path::PathBuf::from(".")) } else { std::path::PathBuf::from(&self.config.output.save_directory) }; let path = save_dir.join(&filename); match std::fs::write(&path, build_report_text(input, result)) { Ok(()) => { self.save_status = Some(format!("Saved: {}", path.display())); let _ = std::process::Command::new("explorer").arg(save_dir).spawn(); } Err(e) => { self.save_status = Some(format!("Save failed: {e}")); } } } // ─── UI renderers ───────────────────────────────────────────────────── fn render_toolbar(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(RichText::new("Ticker:").strong()); let resp = ui.add( TextEdit::singleline(&mut self.ticker_input) .desired_width(120.0) .hint_text("e.g. AAPL"), ); if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { let sym = self.ticker_input.clone(); self.trigger_fetch(&sym, false); } let loading = self.fetch_state == FetchState::Loading; if ui.add_enabled(!loading, egui::Button::new("Analyze")).clicked() { let sym = self.ticker_input.clone(); self.trigger_fetch(&sym, false); } // Watch button let watched = self.current_ticker_watched(); let can_watch = self.can_watch(); let watch_label = if watched { "✓ Watching" } else { "+ Watch" }; let watch_btn = ui.add_enabled(can_watch, egui::Button::new(watch_label)); if watch_btn.clicked() { if watched { let sym = self.ticker_input.trim().to_uppercase(); self.remove_from_watchlist(&sym); } else { self.add_to_watchlist(); } } if watch_btn.hovered() && watched { egui::show_tooltip_text(ui.ctx(), egui::Id::new("watch_tip"), "Click to remove from watchlist"); } if ui.add_enabled(!loading, egui::Button::new("⟳ Refresh")).clicked() { let sym = self.ticker_input.clone(); self.trigger_fetch(&sym, true); } if loading { ui.spinner(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("⚙ Settings").clicked() { self.show_settings = !self.show_settings; } let wl_label = if self.show_watchlist_panel { "▼ Watchlist" } else { "▲ Watchlist" }; if ui.button(wl_label).clicked() { self.show_watchlist_panel = !self.show_watchlist_panel; } }); }); } fn render_status_bar(&self, ui: &mut egui::Ui) { ui.horizontal(|ui| { let color = match &self.fetch_state { FetchState::Error(_) => Color32::from_rgb(231, 76, 60), FetchState::Loading => Color32::from_rgb(241, 196, 15), FetchState::Done => Color32::from_rgb(39, 174, 96), FetchState::Idle => Color32::GRAY, }; ui.label(RichText::new(format!("Status: {}", self.status_message)).color(color)); if let Some(ref msg) = self.save_status { ui.separator(); ui.label(RichText::new(msg).color(Color32::from_rgb(100, 200, 255))); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(RichText::new("v0.3.0").color(Color32::GRAY)); }); }); } fn render_watchlist_panel(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(RichText::new("WATCHLIST").strong()); ui.separator(); if ui.button("Run Check Now").clicked() { self.trigger_bg_check_now(); self.status_message = "Watchlist check triggered…".to_string(); } }); if self.watchlist.tickers.is_empty() { ui.label( RichText::new("No tickers watched. Analyze a ticker and click [+ Watch] to add it.") .color(Color32::GRAY) .italics(), ); return; } let now = Utc::now(); let to_remove: std::cell::RefCell> = std::cell::RefCell::new(None); let to_load: std::cell::RefCell> = std::cell::RefCell::new(None); egui::Grid::new("watchlist_grid") .striped(true) .min_col_width(80.0) .show(ui, |ui| { // Header ui.label(RichText::new("Symbol").strong()); ui.label(RichText::new("Signal").strong()); ui.label(RichText::new("Score").strong()); ui.label(RichText::new("Last Checked").strong()); ui.label(""); // remove button column ui.end_row(); for entry in &self.watchlist.tickers { let sym = entry.symbol.clone(); let btn = ui.selectable_label(false, RichText::new(&sym).strong()); if btn.clicked() { *to_load.borrow_mut() = Some(sym.clone()); } btn.on_hover_text("Click to load analysis"); // Signal with color let sig_color = match entry.last_signal.as_str() { "BUY" => Color32::from_rgb(39, 174, 96), "SELL" => Color32::from_rgb(231, 76, 60), _ => Color32::from_rgb(241, 196, 15), }; ui.label(RichText::new(&entry.last_signal).color(sig_color).strong()); // Score ui.label(format!("{:.2}", entry.last_score)); // Age let mins = (now - entry.last_checked).num_minutes(); let age = if mins < 2 { "just now".to_string() } else if mins < 60 { format!("{mins} min ago") } else { format!("{:.0} hr ago", mins as f64 / 60.0) }; ui.label(age); // Remove button if ui .small_button(RichText::new("✕").color(Color32::from_rgb(180, 60, 60))) .clicked() { *to_remove.borrow_mut() = Some(sym.clone()); } ui.end_row(); } }); if let Some(sym) = to_load.into_inner() { self.ticker_input = sym.clone(); self.trigger_fetch(&sym, false); } if let Some(sym) = to_remove.into_inner() { self.remove_from_watchlist(&sym); } } fn render_settings_window(&mut self, ctx: &Context) { let mut open = self.show_settings; egui::Window::new("Settings") .open(&mut open) .resizable(true) .min_width(380.0) .show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { // ── User ────────────────────────────────────────── ui.heading("User"); ui.horizontal(|ui| { ui.label("Name:"); ui.text_edit_singleline(&mut self.config.user.name); }); ui.add_space(8.0); // ── Preferences ─────────────────────────────────── ui.heading("Analysis Preferences"); ui.horizontal(|ui| { ui.label("Risk tolerance:"); egui::ComboBox::from_id_source("risk_tol") .selected_text(&self.config.preferences.default_risk_tolerance) .show_ui(ui, |ui| { ui.selectable_value( &mut self.config.preferences.default_risk_tolerance, "low".into(), "Low", ); ui.selectable_value( &mut self.config.preferences.default_risk_tolerance, "medium".into(), "Medium", ); ui.selectable_value( &mut self.config.preferences.default_risk_tolerance, "high".into(), "High", ); }); }); ui.checkbox( &mut self.config.preferences.dividend_focus, "Prioritize dividend analysis", ); ui.checkbox( &mut self.config.preferences.avoid_borderline, "Widen PASS zone (avoid borderline calls)", ); ui.checkbox( &mut self.config.preferences.start_minimized, "Start minimized to system tray", ); ui.add_space(8.0); // ── Cache ───────────────────────────────────────── ui.heading("Cache"); ui.horizontal(|ui| { ui.label("Cache TTL (minutes):"); ui.add(egui::DragValue::new(&mut self.config.cache.ttl_minutes) .speed(1) .clamp_range(1..=120)); }); ui.add_space(8.0); // ── Output ──────────────────────────────────────── ui.heading("Report Output"); ui.checkbox(&mut self.config.output.auto_save, "Auto-save report after analysis"); ui.horizontal(|ui| { ui.label("Format:"); egui::ComboBox::from_id_source("save_fmt") .selected_text(&self.config.output.save_format) .show_ui(ui, |ui| { ui.selectable_value( &mut self.config.output.save_format, "txt".into(), "Plain text (.txt)", ); ui.selectable_value( &mut self.config.output.save_format, "md".into(), "Markdown (.md)", ); }); }); ui.horizontal(|ui| { ui.label("Save directory:"); ui.text_edit_singleline(&mut self.config.output.save_directory) .on_hover_text("Leave blank to use Desktop"); }); ui.add_space(8.0); // ── Watchlist ───────────────────────────────────── ui.heading("Watchlist"); ui.horizontal(|ui| { ui.label("Check interval (minutes):"); ui.add(egui::DragValue::new( &mut self.config.watchlist_check.interval_minutes, ) .speed(1) .clamp_range(5..=1440)); }); ui.label( RichText::new("⚠ Interval change takes effect on next app restart.") .color(Color32::GRAY) .small(), ); ui.add_space(8.0); // ── Discord ─────────────────────────────────────── ui.heading("Discord Notifications"); ui.horizontal(|ui| { ui.label("Webhook URL:"); ui.add( TextEdit::singleline(&mut self.config.discord.webhook_url) .desired_width(240.0) .hint_text("https://discord.com/api/webhooks/…"), ); }); ui.checkbox( &mut self.config.discord.notify_on_change, "Notify on signal change (BUY/SELL/PASS flip)", ); ui.checkbox( &mut self.config.discord.notify_on_drift, "Notify on score drift ≥ 0.2 (without signal flip)", ); if !self.config.discord.webhook_url.is_empty() { if ui.button("🔔 Send test notification").clicked() { let url = self.config.discord.webhook_url.clone(); self.tokio_rt.spawn(async move { let _ = crate::discord::send_signal_embed( &url, "TEST", "Tickr Test", "PASS", "BUY", // We need a dummy AnalysisResult — just log the error &make_dummy_result(), ) .await; }); self.status_message = "Test notification sent.".to_string(); } } ui.add_space(8.0); // ── Updates ─────────────────────────────────────── ui.heading("Updates"); let auto_changed = ui.checkbox( &mut self.config.updater.auto_update, "Auto-update when new version is available", ).changed(); if auto_changed { let _ = crate::config::save(&self.config); } ui.horizontal(|ui| { let checking = self.update_in_progress; if ui.add_enabled(!checking, egui::Button::new("🔍 Check Now")).clicked() { self.trigger_update_check(); } if self.update_in_progress { ui.spinner(); ui.label(RichText::new("Downloading update…").color(Color32::from_rgb(241, 196, 15))); } else if let Some((ref tag, ref url)) = self.update_available.clone() { ui.label( RichText::new(format!("Update available: {tag}")) .color(Color32::from_rgb(39, 174, 96)) .strong(), ); if ui.button("⬇ Apply Update").clicked() { self.update_in_progress = true; let url2 = url.clone(); self.tokio_rt.spawn(async move { if let Err(e) = crate::updater::download_and_swap(&url2).await { eprintln!("Update failed: {e}"); } }); } } else { ui.label( RichText::new(format!("Tickr v{} — Up to date", crate::VERSION)) .color(Color32::GRAY), ); } }); ui.add_space(12.0); // ── Save button ─────────────────────────────────── if ui.button("💾 Save Settings").clicked() { if let Err(e) = crate::config::save(&self.config) { self.status_message = format!("Failed to save settings: {e}"); } else { self.cache = CacheStore::new(self.config.cache.ttl_minutes); self.status_message = "Settings saved.".to_string(); } } }); }); if !open { self.show_settings = false; } } fn render_earnings_warning(&self, ui: &mut egui::Ui) { if let Some((AnalysisInput::Equity(data), _)) = &self.current_analysis { if let Some(next) = data.next_earnings_date { let today = Utc::now().date_naive(); let days = (next - today).num_days(); if days >= 0 && days <= 7 { let frame = egui::Frame::none() .fill(Color32::from_rgb(80, 60, 0)) .inner_margin(6.0); frame.show(ui, |ui| { ui.label( RichText::new(format!( "⚠ Earnings in {days} day{} — higher uncertainty", if days == 1 { "" } else { "s" } )) .color(Color32::YELLOW), ); }); } } } } fn poll_tray_events(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { // Drain icon-level events (single/double click → show window) while let Ok(event) = self.tray_ev_rx.try_recv() { match event { tray_icon::TrayIconEvent::Click { .. } => self.show_window(ctx), _ => {} } } // Drain context-menu events while let Ok(event) = self.menu_ev_rx.try_recv() { if self.tray_open_id.as_ref() == Some(&event.id) { self.show_window(ctx); } else if self.tray_check_id.as_ref() == Some(&event.id) { self.trigger_bg_check_now(); self.status_message = "Watchlist check triggered…".to_string(); } else if self.tray_quit_id.as_ref() == Some(&event.id) { self.should_quit = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } } } // ─── eframe::App impl ───────────────────────────────────────────────────── impl eframe::App for TickrApp { fn update(&mut self, ctx: &Context, frame: &mut eframe::Frame) { // Handle window close button if ctx.input(|i| i.viewport().close_requested()) { if self.should_quit { if let Some(ref tx) = self.bg_cmd_tx { let _ = tx.send(BgCmd::Quit); } // Let it close (do not cancel) } else { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.hide_to_tray = true; } } self.poll_fetch_result(); self.poll_bg_results(); self.poll_update_results(); self.poll_tray_events(ctx, frame); // Apply pending hide-to-tray if self.hide_to_tray { self.hide_to_tray = false; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); self.window_visible = false; } // Keep event loop alive for tray responsiveness when window is hidden if !self.window_visible { ctx.request_repaint_after(std::time::Duration::from_millis(300)); return; // Don't render UI when hidden } if self.fetch_state == FetchState::Loading { ctx.request_repaint(); } if self.show_settings { self.render_settings_window(ctx); } egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { ui.add_space(4.0); self.render_toolbar(ui); ui.add_space(4.0); self.render_earnings_warning(ui); }); if self.show_watchlist_panel { egui::TopBottomPanel::bottom("watchlist_panel") .resizable(true) .default_height(160.0) .show(ctx, |ui| { self.render_watchlist_panel(ui); }); } egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { ui.add_space(2.0); self.render_status_bar(ui); ui.add_space(2.0); }); egui::CentralPanel::default().show(ctx, |ui| { if let Some((input, result)) = &self.current_analysis.clone() { let available = ui.available_size(); let panel_width = available.x * 0.35; ui.columns(2, |cols| { cols[0].set_width(panel_width); data_panel::render(&mut cols[0], input, result); cols[1].vertical(|ui| { let save_h = 30.0; let analysis_h = available.y - save_h - 8.0; egui::ScrollArea::vertical() .id_source("analysis_outer_scroll") .max_height(analysis_h) .show(ui, |ui| { analysis_panel::render(ui, result); }); ui.add_space(4.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("💾 Save Report").clicked() { self.save_report(); } }); }); }); } else { ui.centered_and_justified(|ui| { ui.label( RichText::new("Enter a ticker symbol above and click Analyze") .color(Color32::GRAY) .size(16.0), ); }); } }); } } // ─── Background task ────────────────────────────────────────────────────── async fn background_task( mut cmd_rx: tokio::sync::mpsc::UnboundedReceiver, result_tx: mpsc::Sender, update_tx: mpsc::Sender, initial_entries: Vec, config: Config, ) { let watchlist_secs = config.watchlist_check.interval_minutes * 60; let mut watchlist_interval = tokio::time::interval(tokio::time::Duration::from_secs(watchlist_secs)); watchlist_interval.tick().await; // skip immediate first tick let mut update_interval = tokio::time::interval(tokio::time::Duration::from_secs(60 * 60)); update_interval.tick().await; // skip immediate first tick let mut entries = initial_entries; loop { tokio::select! { _ = watchlist_interval.tick() => { run_watchlist_check(&entries, &result_tx, &config).await; } _ = update_interval.tick() => { if config.updater.enabled { run_update_check(&update_tx).await; } } Some(cmd) = cmd_rx.recv() => { match cmd { BgCmd::UpdateWatchlist(e) => entries = e, BgCmd::RunNow => run_watchlist_check(&entries, &result_tx, &config).await, BgCmd::CheckUpdate => run_update_check(&update_tx).await, BgCmd::Quit => break, } } } } } async fn run_update_check(update_tx: &mpsc::Sender) { match crate::updater::check_for_update().await { Ok(Some((tag, url))) => { let _ = update_tx.send(UpdateCheckResult::Available(tag, url)); } Ok(None) => { let _ = update_tx.send(UpdateCheckResult::UpToDate); } Err(e) => eprintln!("Update check failed: {e}"), } } async fn run_watchlist_check( entries: &[WatchlistEntry], result_tx: &mpsc::Sender, config: &Config, ) { for entry in entries { match data::fetch::fetch_ticker(&entry.symbol).await { Ok(input) => { let result = analysis::analyze(&input, config); let _ = result_tx.send(BgCheckResult::Entry { symbol: entry.symbol.clone(), input, result, prev_signal: entry.last_signal.clone(), prev_score: entry.last_score, }); } Err(e) => { eprintln!("Watchlist check failed for {}: {e}", entry.symbol); } } } // Signal that the full cycle is complete so the UI thread can fire the batch snapshot let _ = result_tx.send(BgCheckResult::CycleDone); } // ─── Tray setup ─────────────────────────────────────────────────────────── fn setup_tray( rgba: Vec, size: u32, watched_count: usize, ) -> ( Option, Option, Option, Option, ) { use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem}; use tray_icon::TrayIconBuilder; let icon = match tray_icon::Icon::from_rgba(rgba, size, size) { Ok(i) => i, Err(e) => { eprintln!("Could not create tray icon: {e}"); return (None, None, None, None); } }; let open_item = MenuItem::new("Open Tickr", true, None); let sep1 = PredefinedMenuItem::separator(); let check_item = MenuItem::new("Run Check Now", true, None); let sep2 = PredefinedMenuItem::separator(); let quit_item = MenuItem::new("Quit", true, None); let open_id = open_item.id().clone(); let check_id = check_item.id().clone(); let quit_id = quit_item.id().clone(); let menu = Menu::new(); let _ = menu.append_items(&[ &open_item, &sep1, &check_item, &sep2, &quit_item, ]); let n = watched_count; let tooltip = format!("Tickr — {n} ticker{} watched", if n == 1 { "" } else { "s" }); match TrayIconBuilder::new() .with_icon(icon) .with_tooltip(tooltip) .with_menu(Box::new(menu)) .build() { Ok(tray) => (Some(tray), Some(open_id), Some(check_id), Some(quit_id)), Err(e) => { eprintln!("Could not create system tray: {e}"); (None, None, None, None) } } } // ─── Report builder ─────────────────────────────────────────────────────── fn build_report_text(input: &AnalysisInput, result: &AnalysisResult) -> String { use std::fmt::Write; let mut out = String::new(); let sym = input.symbol(); let date = Utc::now().format("%Y-%m-%d"); let fetched = input.fetched_at().format("%Y-%m-%d %H:%M UTC"); let _ = writeln!(out, "TICKR ANALYSIS REPORT"); let _ = writeln!(out, "Ticker: {sym}"); let _ = writeln!(out, "Date: {date}"); let _ = writeln!(out, "Data fetched: {fetched}"); let _ = writeln!(out, "{}", "=".repeat(60)); let _ = writeln!(out); let _ = writeln!(out, "FINAL OPINION: {}", result.decision.label()); let _ = writeln!(out, "Confidence: {}", result.confidence.label()); let _ = writeln!(out, "Risk Level: {}", result.risk_level.label()); let _ = writeln!(out); for (title, content) in &[ ("ONE-SENTENCE VERDICT", result.section_verdict.as_str()), ("WHAT THIS COMPANY DOES", result.section_what_company_does.as_str()), ("WHY THIS IS THE OPINION", result.section_why_opinion.as_str()), ("DIVIDEND / INCOME CHECK", result.section_dividend.as_str()), ("GROWTH CHECK", result.section_growth.as_str()), ("VALUATION CHECK", result.section_valuation.as_str()), ("WHAT WOULD CHANGE MY MIND", result.section_change_mind.as_str()), ("WHAT YOU SHOULD STUDY NEXT", result.section_study_next.as_str()), ("PLAIN ENGLISH TRANSLATION", result.section_translation.as_str()), ] { let _ = writeln!(out, "{}", "-".repeat(40)); let _ = writeln!(out, "{title}"); let _ = writeln!(out, "{content}"); let _ = writeln!(out); } if !result.section_looks_good.is_empty() { let _ = writeln!(out, "{}", "-".repeat(40)); let _ = writeln!(out, "WHAT LOOKS GOOD"); for item in &result.section_looks_good { let _ = writeln!(out, " ✓ {item}"); } let _ = writeln!(out); } if !result.section_worries.is_empty() { let _ = writeln!(out, "{}", "-".repeat(40)); let _ = writeln!(out, "WHAT WORRIES ME"); for item in &result.section_worries { let _ = writeln!(out, " • {item}"); } let _ = writeln!(out); } let _ = writeln!(out, "{}", "=".repeat(60)); let _ = writeln!(out, "FINAL ANSWER: {}", result.section_final_answer); let _ = writeln!(out, "REASON: {}", result.section_final_reason); out } // ─── Dummy result for test Discord notification ──────────────────────────── fn make_dummy_result() -> AnalysisResult { use crate::analysis::{Confidence, Decision, RiskLevel}; AnalysisResult { decision: Decision::Buy, confidence: Confidence::Medium, risk_level: RiskLevel::Medium, raw_score: 0.7, final_score: 0.7, force_pass_reason: None, category_scores: vec![], speculation_flags: vec![], hype_flags: vec![], analyst_trend: None, section_verdict: "Test notification from Tickr".to_string(), section_what_company_does: String::new(), section_why_opinion: "This is a test Discord notification.".to_string(), section_looks_good: vec![], section_worries: vec![], section_dividend: String::new(), section_growth: String::new(), section_valuation: String::new(), section_change_mind: String::new(), section_study_next: String::new(), section_translation: String::new(), section_final_answer: "BUY".to_string(), section_final_reason: "Test".to_string(), } }