diff --git a/src/tui.zig b/src/tui.zig index 8aa34ea..75c22ff 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -334,12 +334,14 @@ pub const App = struct { quote_timestamp: i64 = 0, // Track whether earnings tab should be disabled (ETF, no data) earnings_disabled: bool = false, + earnings_error: ?[]const u8 = null, // error message to show in content area // ETF profile (loaded lazily on quote tab) etf_profile: ?zfin.EtfProfile = null, etf_loaded: bool = false, // Analysis tab state analysis_result: ?zfin.analysis.AnalysisResult = null, analysis_loaded: bool = false, + analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio) classification_map: ?zfin.classification.ClassificationMap = null, account_map: ?zfin.analysis.AccountMap = null, @@ -400,7 +402,7 @@ pub const App = struct { for (tabs) |t| { const lbl_len: i16 = @intCast(t.label().len); if (mouse.col >= col and mouse.col < col + lbl_len) { - if (t == .earnings and self.earnings_disabled) return; + if (self.isTabDisabled(t)) return; self.active_tab = t; self.scroll_offset = 0; self.loadTabData(); @@ -614,7 +616,7 @@ pub const App = struct { const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); if (idx < tabs.len) { const target = tabs[idx]; - if (target == .earnings and self.earnings_disabled) return; + if (self.isTabDisabled(target)) return; self.active_tab = target; self.scroll_offset = 0; self.loadTabData(); @@ -973,6 +975,7 @@ pub const App = struct { self.perf_loaded = false; self.earnings_loaded = false; self.earnings_disabled = false; + self.earnings_error = null; self.options_loaded = false; self.etf_loaded = false; self.options_cursor = 0; @@ -1031,6 +1034,8 @@ pub const App = struct { }, .earnings => { self.earnings_loaded = false; + self.earnings_disabled = false; + self.earnings_error = null; self.freeEarnings(); }, .options => { @@ -1081,6 +1086,7 @@ pub const App = struct { if (!self.options_loaded) options_tab.loadData(self); }, .analysis => { + if (self.analysis_disabled) return; if (!self.analysis_loaded) self.loadAnalysisData(); }, } @@ -1216,7 +1222,7 @@ pub const App = struct { for (tabs) |t| { const lbl = t.label(); const is_active = t == self.active_tab; - const is_disabled = t == .earnings and self.earnings_disabled; + const is_disabled = self.isTabDisabled(t); const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style; for (lbl) |ch| { @@ -1247,6 +1253,11 @@ pub const App = struct { return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; } + fn isTabDisabled(self: *App, t: Tab) bool { + return (t == .earnings and self.earnings_disabled) or + (t == .analysis and self.analysis_disabled); + } + fn isSymbolSelected(self: *App) bool { // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' if (self.active_tab != .portfolio) return false; @@ -1476,7 +1487,9 @@ pub const App = struct { fn nextTab(self: *App) void { const idx = @intFromEnum(self.active_tab); var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0; - if (tabs[next_idx] == .earnings and self.earnings_disabled) + // Skip disabled tabs (earnings for ETFs, analysis without portfolio) + var tries: usize = 0; + while (self.isTabDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1) next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0; self.active_tab = tabs[next_idx]; } @@ -1484,7 +1497,9 @@ pub const App = struct { fn prevTab(self: *App) void { const idx = @intFromEnum(self.active_tab); var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1; - if (tabs[prev_idx] == .earnings and self.earnings_disabled) + // Skip disabled tabs (earnings for ETFs, analysis without portfolio) + var tries: usize = 0; + while (self.isTabDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1) prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1; self.active_tab = tabs[prev_idx]; } @@ -1748,6 +1763,11 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co app_inst.active_tab = .quote; } + // Disable analysis tab when no portfolio is loaded (analysis requires portfolio) + if (app_inst.portfolio == null) { + app_inst.analysis_disabled = true; + } + // 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| { diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 1011c98..75650fc 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -11,16 +11,23 @@ const StyledLine = tui.StyledLine; pub fn loadData(app: *App) void { app.earnings_loaded = true; + app.earnings_error = null; app.freeEarnings(); const result = app.svc.getEarnings(app.symbol) catch |err| { switch (err) { - zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"), + zfin.DataError.NoApiKey => { + app.earnings_error = "No API key. Set FINNHUB_API_KEY (free at finnhub.io)"; + app.setStatus("No API key. Set FINNHUB_API_KEY"); + }, zfin.DataError.FetchFailed => { app.earnings_disabled = true; app.setStatus("No earnings data (ETF/index?)"); }, - else => app.setStatus("Error loading earnings"), + else => { + app.earnings_error = "Error loading earnings data. Press r to retry."; + app.setStatus("Error loading earnings"); + }, } return; }; @@ -47,7 +54,7 @@ pub fn loadData(app: *App) void { // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { - return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp); + return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp, app.earnings_error); } /// Render earnings tab content. Pure function — no App dependency. @@ -58,6 +65,7 @@ pub fn renderEarningsLines( earnings_disabled: bool, earnings_data: ?[]const zfin.EarningsEvent, earnings_timestamp: i64, + earnings_error: ?[]const u8, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; @@ -82,7 +90,11 @@ pub fn renderEarningsLines( try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const ev = earnings_data orelse { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() }); + if (earnings_error) |err_msg| { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{err_msg}), .style = th.warningStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() }); + } return lines.toOwnedSlice(arena); }; if (ev.len == 0) { @@ -127,7 +139,7 @@ test "renderEarningsLines with earnings data" { .estimate = 1.50, .actual = 1.65, }}; - const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0); + const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0, null); // blank + header + blank + col_header + data_row + blank + count = 7 try testing.expectEqual(@as(usize, 7), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null); @@ -142,7 +154,7 @@ test "renderEarningsLines no symbol" { const arena = arena_state.allocator(); const th = theme_mod.default_theme; - const lines = try renderEarningsLines(arena, th, "", false, null, 0); + const lines = try renderEarningsLines(arena, th, "", false, null, 0, null); try testing.expectEqual(@as(usize, 2), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null); } @@ -153,7 +165,7 @@ test "renderEarningsLines disabled" { const arena = arena_state.allocator(); const th = theme_mod.default_theme; - const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0); + const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0, null); try testing.expectEqual(@as(usize, 2), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null); } @@ -164,7 +176,18 @@ test "renderEarningsLines no data" { const arena = arena_state.allocator(); const th = theme_mod.default_theme; - const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0); + const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, null); try testing.expectEqual(@as(usize, 4), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null); } + +test "renderEarningsLines with error message" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FINNHUB_API_KEY"); + try testing.expectEqual(@as(usize, 4), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[3].text, "FINNHUB_API_KEY") != null); +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index c4cb0ca..530003a 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1025,6 +1025,7 @@ pub fn reloadPortfolioFile(app: *App) void { if (app.analysis_result) |*ar| ar.deinit(app.allocator); app.analysis_result = null; app.analysis_loaded = false; + app.analysis_disabled = false; // Portfolio loaded; analysis is now possible // If currently on the analysis tab, eagerly recompute so the user // doesn't see an error message before switching away and back.