diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 2525adf..63b345f 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -3,6 +3,49 @@ 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| { @@ -60,12 +103,21 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); } + // Progress callback for per-symbol output + var progress_ctx = CliProgress{ + .svc = svc, + .color = color, + .index_offset = 0, + .grand_total = all_syms_count, + }; + // Load prices for stock/ETF positions - const load_result = svc.loadPrices(syms, &prices, force_refresh); + const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback()); fail_count = load_result.fail_count; // Fetch watch symbol candles (for watchlist display, not portfolio value) - _ = svc.loadPrices(watch_syms.items, &prices, force_refresh); + progress_ctx.index_offset = syms.len; + _ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback()); // Summary line { diff --git a/src/service.zig b/src/service.zig index b223c61..cc46cf0 100644 --- a/src/service.zig +++ b/src/service.zig @@ -466,6 +466,31 @@ pub const DataService = struct { latest_date: ?Date, }; + /// Status emitted for each symbol during price loading. + pub const SymbolStatus = enum { + /// Price resolved from fresh cache. + cached, + /// About to attempt an API fetch (emitted before the network call). + fetching, + /// Price fetched successfully from API. + fetched, + /// API fetch failed but stale cached price was used. + failed_used_stale, + /// API fetch failed and no cached price exists. + failed, + }; + + /// Callback for progress reporting during price loading. + /// `context` is an opaque pointer to caller-owned state. + pub const ProgressCallback = struct { + context: *anyopaque, + on_progress: *const fn (ctx: *anyopaque, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void, + + fn emit(self: ProgressCallback, index: usize, total: usize, symbol: []const u8, status: SymbolStatus) void { + self.on_progress(self.context, index, total, symbol, status); + } + }; + /// Load current prices for a list of symbols into `prices`. /// /// For each symbol the resolution order is: @@ -474,11 +499,13 @@ pub const DataService = struct { /// 3. Stale cache -> use last close from expired cache entry /// /// If `force_refresh` is true, cache is invalidated before checking freshness. + /// If `progress` is provided, it is called for each symbol with the outcome. pub fn loadPrices( self: *DataService, symbols: []const []const u8, prices: *std.StringHashMap(f64), force_refresh: bool, + progress: ?ProgressCallback, ) PriceLoadResult { var result = PriceLoadResult{ .cached_count = 0, @@ -487,8 +514,9 @@ pub const DataService = struct { .stale_count = 0, .latest_date = null, }; + const total = symbols.len; - for (symbols) |sym| { + for (symbols, 0..) |sym, i| { if (force_refresh) { self.invalidate(sym, .candles_daily); } @@ -499,9 +527,13 @@ pub const DataService = struct { prices.put(sym, close) catch {}; } result.cached_count += 1; + if (progress) |p| p.emit(i, total, sym, .cached); continue; } + // About to fetch — notify caller (so it can show rate-limit waits etc.) + if (progress) |p| p.emit(i, total, sym, .fetching); + // 2. Try API fetch if (self.getCandles(sym)) |candle_result| { defer self.allocator.free(candle_result.data); @@ -513,6 +545,7 @@ pub const DataService = struct { } } result.fetched_count += 1; + if (progress) |p| p.emit(i, total, sym, .fetched); continue; } else |_| {} @@ -521,6 +554,9 @@ pub const DataService = struct { if (self.getCachedLastClose(sym)) |close| { prices.put(sym, close) catch {}; result.stale_count += 1; + if (progress) |p| p.emit(i, total, sym, .failed_used_stale); + } else { + if (progress) |p| p.emit(i, total, sym, .failed); } } diff --git a/src/tui.zig b/src/tui.zig index 6898c11..3b5b007 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1067,8 +1067,40 @@ const App = struct { var fail_count: usize = 0; var fetch_count: usize = 0; var stale_count: usize = 0; + var failed_syms: [8][]const u8 = undefined; { - const load_result = self.svc.loadPrices(syms, &prices, false); + const TuiProgress = struct { + app: *App, + failed: *[8][]const u8, + fail_n: usize = 0, + + fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void { + const s: *@This() = @ptrCast(@alignCast(ctx)); + switch (status) { + .fetching => { + var buf: [64]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading..."; + s.app.setStatus(msg); + }, + .failed, .failed_used_stale => { + if (s.fail_n < s.failed.len) { + s.failed[s.fail_n] = symbol; + s.fail_n += 1; + } + }, + else => {}, + } + } + + fn callback(s: *@This()) zfin.DataService.ProgressCallback { + return .{ + .context = @ptrCast(s), + .on_progress = onProgress, + }; + } + }; + var tui_progress = TuiProgress{ .app = self, .failed = &failed_syms }; + const load_result = self.svc.loadPrices(syms, &prices, false, tui_progress.callback()); latest_date = load_result.latest_date; fail_count = load_result.fail_count; fetch_count = load_result.fetched_count; @@ -1130,16 +1162,39 @@ const App = struct { // Show warning if any securities failed to load if (fail_count > 0) { var warn_buf: [256]u8 = undefined; - const stale = stale_count; - if (stale > 0 and stale == fail_count) { - const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; - self.setStatus(warn_msg); - } else if (stale > 0) { - const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed ({d} using stale cache) | r/F5 to retry", .{ fail_count, stale }) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + if (fail_count <= 3) { + // Show actual symbol names for easier debugging + var sym_buf: [128]u8 = undefined; + var sym_len: usize = 0; + const show = @min(fail_count, failed_syms.len); + for (0..show) |fi| { + if (sym_len > 0) { + if (sym_len + 2 < sym_buf.len) { + sym_buf[sym_len] = ','; + sym_buf[sym_len + 1] = ' '; + sym_len += 2; + } + } + const s = failed_syms[fi]; + const copy_len = @min(s.len, sym_buf.len - sym_len); + @memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]); + sym_len += copy_len; + } + if (stale_count > 0) { + const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; + self.setStatus(warn_msg); + } else { + const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed"; + self.setStatus(warn_msg); + } } else { - const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; - self.setStatus(warn_msg); + if (stale_count > 0 and stale_count == fail_count) { + const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache"; + self.setStatus(warn_msg); + } else { + const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed"; + self.setStatus(warn_msg); + } } } else if (fetch_count > 0) { var info_buf: [128]u8 = undefined;