Files
tickr/src/app.rs
whitlocktech 168dc9de83
All checks were successful
Build Check / build (push) Successful in 4m2s
bug-001: fix tray menu, add autostart and crash recovery launcher
2026-05-17 09:26:28 -05:00

1250 lines
50 KiB
Rust

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<WatchlistEntry>),
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<String>,
// Async fetch channel
fetch_tx: mpsc::Sender<Result<AnalysisInput, String>>,
fetch_rx: mpsc::Receiver<Result<AnalysisInput, String>>,
tokio_rt: tokio::runtime::Runtime,
// UI state
show_settings: bool,
show_watchlist_panel: bool,
// Watchlist
watchlist: Watchlist,
bg_cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<BgCmd>>,
bg_result_rx: mpsc::Receiver<BgCheckResult>,
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<UpdateCheckResult>,
// 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_icon::TrayIcon>,
tray_open_id: Option<tray_icon::menu::MenuId>,
tray_check_id: Option<tray_icon::menu::MenuId>,
tray_quit_id: Option<tray_icon::menu::MenuId>,
tray_ev_rx: mpsc::Receiver<tray_icon::TrayIconEvent>,
menu_ev_rx: mpsc::Receiver<tray_icon::menu::MenuEvent>,
window_visible: bool,
hide_to_tray: bool,
should_quit: bool,
}
impl TickrApp {
pub fn new(
cc: &eframe::CreationContext<'_>,
config: Config,
icon_rgba: Vec<u8>,
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::<BgCheckResult>();
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::<tray_icon::TrayIconEvent>(64);
let (menu_ev_tx, menu_ev_rx) = mpsc::sync_channel::<tray_icon::menu::MenuEvent>(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::<BgCmd>();
let (update_result_tx, update_result_rx) = mpsc::channel::<UpdateCheckResult>();
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<Vec<(String, String, AnalysisResult)>> =
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<Option<String>> = std::cell::RefCell::new(None);
let to_load: std::cell::RefCell<Option<String>> = 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<BgCmd>,
result_tx: mpsc::Sender<BgCheckResult>,
update_tx: mpsc::Sender<UpdateCheckResult>,
initial_entries: Vec<WatchlistEntry>,
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<UpdateCheckResult>) {
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<BgCheckResult>,
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<u8>,
size: u32,
watched_count: usize,
) -> (
Option<tray_icon::TrayIcon>,
Option<tray_icon::menu::MenuId>,
Option<tray_icon::menu::MenuId>,
Option<tray_icon::menu::MenuId>,
) {
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(),
}
}