fixes for interactive symbol mode
All checks were successful
Generic zig build / build (push) Successful in 32s
All checks were successful
Generic zig build / build (push) Successful in 32s
This commit is contained in:
parent
b1c80180bb
commit
057bca14a1
3 changed files with 57 additions and 13 deletions
30
src/tui.zig
30
src/tui.zig
|
|
@ -334,12 +334,14 @@ pub const App = struct {
|
||||||
quote_timestamp: i64 = 0,
|
quote_timestamp: i64 = 0,
|
||||||
// Track whether earnings tab should be disabled (ETF, no data)
|
// Track whether earnings tab should be disabled (ETF, no data)
|
||||||
earnings_disabled: bool = false,
|
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 (loaded lazily on quote tab)
|
||||||
etf_profile: ?zfin.EtfProfile = null,
|
etf_profile: ?zfin.EtfProfile = null,
|
||||||
etf_loaded: bool = false,
|
etf_loaded: bool = false,
|
||||||
// Analysis tab state
|
// Analysis tab state
|
||||||
analysis_result: ?zfin.analysis.AnalysisResult = null,
|
analysis_result: ?zfin.analysis.AnalysisResult = null,
|
||||||
analysis_loaded: bool = false,
|
analysis_loaded: bool = false,
|
||||||
|
analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio)
|
||||||
classification_map: ?zfin.classification.ClassificationMap = null,
|
classification_map: ?zfin.classification.ClassificationMap = null,
|
||||||
account_map: ?zfin.analysis.AccountMap = null,
|
account_map: ?zfin.analysis.AccountMap = null,
|
||||||
|
|
||||||
|
|
@ -400,7 +402,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl_len: i16 = @intCast(t.label().len);
|
const lbl_len: i16 = @intCast(t.label().len);
|
||||||
if (mouse.col >= col and mouse.col < col + lbl_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.active_tab = t;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -614,7 +616,7 @@ pub const App = struct {
|
||||||
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
||||||
if (idx < tabs.len) {
|
if (idx < tabs.len) {
|
||||||
const target = tabs[idx];
|
const target = tabs[idx];
|
||||||
if (target == .earnings and self.earnings_disabled) return;
|
if (self.isTabDisabled(target)) return;
|
||||||
self.active_tab = target;
|
self.active_tab = target;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -973,6 +975,7 @@ pub const App = struct {
|
||||||
self.perf_loaded = false;
|
self.perf_loaded = false;
|
||||||
self.earnings_loaded = false;
|
self.earnings_loaded = false;
|
||||||
self.earnings_disabled = false;
|
self.earnings_disabled = false;
|
||||||
|
self.earnings_error = null;
|
||||||
self.options_loaded = false;
|
self.options_loaded = false;
|
||||||
self.etf_loaded = false;
|
self.etf_loaded = false;
|
||||||
self.options_cursor = 0;
|
self.options_cursor = 0;
|
||||||
|
|
@ -1031,6 +1034,8 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
self.earnings_loaded = false;
|
self.earnings_loaded = false;
|
||||||
|
self.earnings_disabled = false;
|
||||||
|
self.earnings_error = null;
|
||||||
self.freeEarnings();
|
self.freeEarnings();
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
|
|
@ -1081,6 +1086,7 @@ pub const App = struct {
|
||||||
if (!self.options_loaded) options_tab.loadData(self);
|
if (!self.options_loaded) options_tab.loadData(self);
|
||||||
},
|
},
|
||||||
.analysis => {
|
.analysis => {
|
||||||
|
if (self.analysis_disabled) return;
|
||||||
if (!self.analysis_loaded) self.loadAnalysisData();
|
if (!self.analysis_loaded) self.loadAnalysisData();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -1216,7 +1222,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl = t.label();
|
const lbl = t.label();
|
||||||
const is_active = t == self.active_tab;
|
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;
|
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
|
||||||
|
|
||||||
for (lbl) |ch| {
|
for (lbl) |ch| {
|
||||||
|
|
@ -1247,6 +1253,11 @@ pub const App = struct {
|
||||||
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
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 {
|
fn isSymbolSelected(self: *App) bool {
|
||||||
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
||||||
if (self.active_tab != .portfolio) return false;
|
if (self.active_tab != .portfolio) return false;
|
||||||
|
|
@ -1476,7 +1487,9 @@ pub const App = struct {
|
||||||
fn nextTab(self: *App) void {
|
fn nextTab(self: *App) void {
|
||||||
const idx = @intFromEnum(self.active_tab);
|
const idx = @intFromEnum(self.active_tab);
|
||||||
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
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;
|
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
|
||||||
self.active_tab = tabs[next_idx];
|
self.active_tab = tabs[next_idx];
|
||||||
}
|
}
|
||||||
|
|
@ -1484,7 +1497,9 @@ pub const App = struct {
|
||||||
fn prevTab(self: *App) void {
|
fn prevTab(self: *App) void {
|
||||||
const idx = @intFromEnum(self.active_tab);
|
const idx = @intFromEnum(self.active_tab);
|
||||||
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
|
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;
|
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
|
||||||
self.active_tab = tabs[prev_idx];
|
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;
|
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.
|
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||||
// This runs while the terminal is still in normal mode so output is visible.
|
// This runs while the terminal is still in normal mode so output is visible.
|
||||||
if (app_inst.portfolio) |pf| {
|
if (app_inst.portfolio) |pf| {
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,23 @@ const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
pub fn loadData(app: *App) void {
|
||||||
app.earnings_loaded = true;
|
app.earnings_loaded = true;
|
||||||
|
app.earnings_error = null;
|
||||||
app.freeEarnings();
|
app.freeEarnings();
|
||||||
|
|
||||||
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
||||||
switch (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 => {
|
zfin.DataError.FetchFailed => {
|
||||||
app.earnings_disabled = true;
|
app.earnings_disabled = true;
|
||||||
app.setStatus("No earnings data (ETF/index?)");
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -47,7 +54,7 @@ pub fn loadData(app: *App) void {
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
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.
|
/// Render earnings tab content. Pure function — no App dependency.
|
||||||
|
|
@ -58,6 +65,7 @@ pub fn renderEarningsLines(
|
||||||
earnings_disabled: bool,
|
earnings_disabled: bool,
|
||||||
earnings_data: ?[]const zfin.EarningsEvent,
|
earnings_data: ?[]const zfin.EarningsEvent,
|
||||||
earnings_timestamp: i64,
|
earnings_timestamp: i64,
|
||||||
|
earnings_error: ?[]const u8,
|
||||||
) ![]const StyledLine {
|
) ![]const StyledLine {
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
|
@ -82,7 +90,11 @@ pub fn renderEarningsLines(
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
const ev = earnings_data orelse {
|
const ev = earnings_data orelse {
|
||||||
|
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() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
|
||||||
|
}
|
||||||
return lines.toOwnedSlice(arena);
|
return lines.toOwnedSlice(arena);
|
||||||
};
|
};
|
||||||
if (ev.len == 0) {
|
if (ev.len == 0) {
|
||||||
|
|
@ -127,7 +139,7 @@ test "renderEarningsLines with earnings data" {
|
||||||
.estimate = 1.50,
|
.estimate = 1.50,
|
||||||
.actual = 1.65,
|
.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
|
// blank + header + blank + col_header + data_row + blank + count = 7
|
||||||
try testing.expectEqual(@as(usize, 7), lines.len);
|
try testing.expectEqual(@as(usize, 7), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
|
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 arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
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.expectEqual(@as(usize, 2), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
|
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 arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
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.expectEqual(@as(usize, 2), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
|
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 arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
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.expectEqual(@as(usize, 4), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1025,6 +1025,7 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
||||||
app.analysis_result = null;
|
app.analysis_result = null;
|
||||||
app.analysis_loaded = false;
|
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
|
// If currently on the analysis tab, eagerly recompute so the user
|
||||||
// doesn't see an error message before switching away and back.
|
// doesn't see an error message before switching away and back.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue