From 089e81df5456d744fd351b7c98c2b9b38c4c618f Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 3 Mar 2026 12:00:41 -0800 Subject: [PATCH] ai: provide fetch progress info when tui starts --- src/commands/common.zig | 44 ++++++++++++ src/commands/portfolio.zig | 45 +----------- src/tui.zig | 144 +++++++++++++++++++++++++++++-------- 3 files changed, 160 insertions(+), 73 deletions(-) diff --git a/src/commands/common.zig b/src/commands/common.zig index 3c5d952..33354ae 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -79,6 +79,50 @@ pub fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void { try out.flush(); } +/// Progress callback for loadPrices that prints to stderr. +/// Shared between the CLI portfolio command and TUI pre-fetch. +pub const LoadProgress = struct { + svc: *zfin.DataService, + color: bool, + /// Offset added to index for display (e.g. stock count when loading watch symbols). + index_offset: usize, + /// Grand total across all loadPrices calls (stocks + watch). + grand_total: usize, + + fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { + const self: *LoadProgress = @ptrCast(@alignCast(ctx)); + const display_idx = self.index_offset + index + 1; + switch (status) { + .fetching => { + // Show rate-limit wait before the fetch + if (self.svc.estimateWaitSeconds()) |w| { + if (w > 0) stderrRateLimitWait(w, self.color) catch {}; + } + stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {}; + }, + .cached => { + stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {}; + }, + .fetched => { + // Already showed "(fetching)" — no extra line needed + }, + .failed_used_stale => { + stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {}; + }, + .failed => { + stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {}; + }, + } + } + + pub fn callback(self: *LoadProgress) zfin.DataService.ProgressCallback { + return .{ + .context = @ptrCast(self), + .on_progress = onProgress, + }; + } +}; + // ── Tests ──────────────────────────────────────────────────── test "setFg emits ANSI when color enabled" { diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index b91e017..351174c 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -3,49 +3,6 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; -/// CLI progress context for loadPrices callback. -const CliProgress = struct { - svc: *zfin.DataService, - color: bool, - /// Offset added to index for display (e.g. stock count when loading watch symbols). - index_offset: usize, - /// Grand total across all loadPrices calls (stocks + watch). - grand_total: usize, - - fn onProgress(ctx: *anyopaque, index: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { - const self: *CliProgress = @ptrCast(@alignCast(ctx)); - const display_idx = self.index_offset + index + 1; - switch (status) { - .fetching => { - // Show rate-limit wait before the fetch - if (self.svc.estimateWaitSeconds()) |w| { - if (w > 0) cli.stderrRateLimitWait(w, self.color) catch {}; - } - cli.stderrProgress(symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {}; - }, - .cached => { - cli.stderrProgress(symbol, " (cached)", display_idx, self.grand_total, self.color) catch {}; - }, - .fetched => { - // Already showed "(fetching)" — no extra line needed - }, - .failed_used_stale => { - cli.stderrProgress(symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {}; - }, - .failed => { - cli.stderrProgress(symbol, " FAILED", display_idx, self.grand_total, self.color) catch {}; - }, - } - } - - fn callback(self: *CliProgress) zfin.DataService.ProgressCallback { - return .{ - .context = @ptrCast(self), - .on_progress = onProgress, - }; - } -}; - pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { // Load portfolio from SRF file const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { @@ -104,7 +61,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer } // Progress callback for per-symbol output - var progress_ctx = CliProgress{ + var progress_ctx = cli.LoadProgress{ .svc = svc, .color = color, .index_offset = 0, diff --git a/src/tui.zig b/src/tui.zig index 463549c..47a6e86 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); const fmt = zfin.format; +const cli = @import("commands/common.zig"); const keybinds = @import("tui/keybinds.zig"); const theme_mod = @import("tui/theme.zig"); const chart_mod = @import("tui/chart.zig"); @@ -234,6 +235,7 @@ const App = struct { portfolio_sort_field: PortfolioSortField = .symbol, // current sort column portfolio_sort_dir: SortDirection = .asc, // current sort direction watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render) + prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress) // Options navigation (inline expand/collapse like portfolio) options_cursor: usize = 0, // selected row in flattened options view @@ -1017,34 +1019,6 @@ const App = struct { self.portfolio_loaded = true; self.freePortfolioSummary(); - // Fetch data for watchlist symbols so they have prices to display - // (from both the separate watchlist file and watch lots in the portfolio) - if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { - self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); - } - var wp = &(self.watchlist_prices.?); - if (self.watchlist) |wl| { - for (wl) |sym| { - const result = self.svc.getCandles(sym) catch continue; - defer self.allocator.free(result.data); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; - } - } - } - if (self.portfolio) |pf| { - for (pf.lots) |lot| { - if (lot.lot_type == .watch) { - const sym = lot.priceSymbol(); - const result = self.svc.getCandles(sym) catch continue; - defer self.allocator.free(result.data); - if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; - } - } - } - } - const pf = self.portfolio orelse return; const positions = pf.positions(self.allocator) catch { @@ -1068,7 +1042,57 @@ const App = struct { var fetch_count: usize = 0; var stale_count: usize = 0; var failed_syms: [8][]const u8 = undefined; - { + + if (self.prefetched_prices) |*pp| { + // Use pre-fetched prices from before TUI started (first load only) + // Move stock prices into the working map + for (syms) |sym| { + if (pp.get(sym)) |price| { + prices.put(sym, price) catch {}; + } + } + + // Extract watchlist prices + if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + } + var wp = &(self.watchlist_prices.?); + var pp_iter = pp.iterator(); + while (pp_iter.next()) |entry| { + if (!prices.contains(entry.key_ptr.*)) { + wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + } + } + + pp.deinit(); + self.prefetched_prices = null; + } else { + // Live fetch (refresh path) — fetch watchlist first, then stock prices + if (self.watchlist_prices) |*wp| wp.clearRetainingCapacity() else { + self.watchlist_prices = std.StringHashMap(f64).init(self.allocator); + } + var wp = &(self.watchlist_prices.?); + if (self.watchlist) |wl| { + for (wl) |sym| { + const result = self.svc.getCandles(sym) catch continue; + defer self.allocator.free(result.data); + if (result.data.len > 0) { + wp.put(sym, result.data[result.data.len - 1].close) catch {}; + } + } + } + for (pf.lots) |lot| { + if (lot.lot_type == .watch) { + const sym = lot.priceSymbol(); + const result = self.svc.getCandles(sym) catch continue; + defer self.allocator.free(result.data); + if (result.data.len > 0) { + wp.put(sym, result.data[result.data.len - 1].close) catch {}; + } + } + } + + // Fetch stock prices with TUI status-bar progress const TuiProgress = struct { app: *App, failed: *[8][]const u8, @@ -3705,6 +3729,68 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co app_inst.active_tab = .quote; } + // Pre-fetch portfolio prices before TUI starts, with stderr progress. + // This runs while the terminal is still in normal mode so output is visible. + if (app_inst.portfolio) |pf| { + const syms = pf.stockSymbols(allocator) catch null; + defer if (syms) |s| allocator.free(s); + + // Collect watchlist symbols + var watch_syms: std.ArrayList([]const u8) = .empty; + defer watch_syms.deinit(allocator); + { + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {}; + if (app_inst.watchlist) |wl| { + for (wl) |sym_w| { + if (!seen.contains(sym_w)) { + seen.put(sym_w, {}) catch {}; + watch_syms.append(allocator, sym_w) catch {}; + } + } + } + for (pf.lots) |lot| { + if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) { + seen.put(lot.priceSymbol(), {}) catch {}; + watch_syms.append(allocator, lot.priceSymbol()) catch {}; + } + } + } + + const stock_count = if (syms) |ss| ss.len else 0; + const total_count = stock_count + watch_syms.items.len; + + if (total_count > 0) { + var prices = std.StringHashMap(f64).init(allocator); + + var progress = cli.LoadProgress{ + .svc = svc, + .color = true, + .index_offset = 0, + .grand_total = total_count, + }; + + if (syms) |ss| { + const result = svc.loadPrices(ss, &prices, false, progress.callback()); + progress.index_offset = stock_count; + + if (result.fetched_count > 0 or result.fail_count > 0) { + var msg_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ ss.len, result.cached_count, result.fetched_count, result.fail_count }) catch "Done loading\n"; + cli.stderrPrint(msg) catch {}; + } + } + + // Load watchlist prices + if (watch_syms.items.len > 0) { + _ = svc.loadPrices(watch_syms.items, &prices, false, progress.callback()); + } + + app_inst.prefetched_prices = prices; + } + } + defer if (app_inst.portfolio) |*pf| pf.deinit(); defer freeWatchlist(allocator, app_inst.watchlist); defer app_inst.deinitData();