const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // Earnings tab has no tab-local keybinds today. Refresh is global // (`r`); there's no per-tab UX beyond viewing the table. The empty // enum is the explicit placeholder per the framework contract — no // implicit defaults. pub const Action = enum {}; // ── Tab-private state ───────────────────────────────────────── pub const State = struct { /// Whether `init`/`activate` has populated `data` (or set /// `disabled` / `error_msg`). Distinct from "data is non-null" /// because we may have explicitly cached "this symbol has no /// earnings" without a payload. loaded: bool = false, /// Cached event list, oldest-last after the sort in `activate`. /// Owned by the State; freed in `deinit` and `reload`. data: ?[]zfin.EarningsEvent = null, /// Source-of-data unix-epoch timestamp captured at fetch time; /// drives the "data Xs ago" header readout. timestamp: i64 = 0, /// `true` when the symbol legitimately has no earnings data /// (ETF, index, …) — distinct from a fetch failure. Stops the /// tab from re-fetching every activation; surfaces a friendlier /// "not available" message. disabled: bool = false, /// Human-readable error message displayed inline in the content /// area when `data` is null and `disabled` is false. error_msg: ?[]const u8 = null, }; // ── Tab framework contract ──────────────────────────────────── pub const tab = struct { pub const ActionT = Action; pub const StateT = State; /// Display name for the tab bar. pub const label: []const u8 = "Earnings"; /// No tab-local bindings — refresh is global. Empty placeholder. pub const default_bindings: []const framework.TabBinding(Action) = &.{}; /// One label per Action variant — also empty. pub const action_labels = std.enums.EnumArray(Action, []const u8).initFill(""); /// Status-line hints — empty. pub const status_hints: []const Action = &.{}; /// One-time construction. State already has zero-initialized /// defaults via field defaults; nothing to allocate up front. pub fn init(state: *State, app: *App) !void { _ = app; state.* = .{}; } /// One-time teardown. Free any allocated payloads. pub fn deinit(state: *State, app: *App) void { if (state.data) |e| app.allocator.free(e); state.* = .{}; } /// Called when the earnings tab becomes the active tab. Lazy- /// loads on first activation per symbol; subsequent activations /// for the same symbol short-circuit on `loaded`. pub fn activate(state: *State, app: *App) !void { if (state.disabled) return; if (state.loaded) return; loadData(state, app); } /// No-op — nothing transient to release on tab switch. pub const deactivate = framework.noopDeactivate(State); /// Force re-fetch on user request (refresh keybind, symbol /// change, etc). Frees current payload + clears flags + /// re-runs the fetch path. pub fn reload(state: *State, app: *App) !void { // Clear every flag so loadData has the same starting // conditions as a fresh activation. if (state.data) |e| app.allocator.free(e); state.* = .{}; loadData(state, app); } pub const tick = framework.noopTick(State); /// No tab-local actions — `Action` enum is empty, so this /// switch has no arms. Provided for contract completeness. pub fn handleAction(state: *State, app: *App, action: Action) void { _ = state; _ = app; switch (action) {} } /// Symbol-change reset. Drops cached payload + flags so the /// next `activate` re-fetches for the new symbol. Distinct /// from `reload` (no fetch is triggered here). pub fn onSymbolChange(state: *State, app: *App) void { if (state.data) |e| app.allocator.free(e); state.* = .{}; } /// Earnings is disabled when the active symbol's data layer /// reported "no earnings for this symbol" (ETF/index). The /// flag is sticky for the symbol's session; cleared by /// `resetSymbolData` on App. pub fn isDisabled(app: *App) bool { return app.states.earnings.disabled; } }; // ── Data loading ────────────────────────────────────────────── // // Internal helper invoked by both `activate` (lazy first-load) // and `reload` (explicit refresh). Sets `loaded`, populates // `data` / `disabled` / `error_msg` based on the data-service // result, and posts a status message. fn loadData(state: *State, app: *App) void { state.loaded = true; state.error_msg = null; const result = app.svc.getEarnings(app.symbol) catch |err| { switch (err) { zfin.DataError.NoApiKey => { state.error_msg = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)"; app.setStatus("No API key. Set FMP_API_KEY"); }, zfin.DataError.FetchFailed => { state.disabled = true; app.setStatus("No earnings data (ETF/index?)"); }, else => { state.error_msg = "Error loading earnings data. Press r to retry."; app.setStatus("Error loading earnings"); }, } return; }; state.data = result.data; state.timestamp = result.timestamp; // Sort newest-first — this is what users expect on earnings tables // everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant // quarter on the first visible row. if (result.data.len > 1) { std.mem.sort(zfin.EarningsEvent, result.data, {}, struct { fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool { return a.date.days > b.date.days; } }.f); } if (result.data.len == 0) { state.disabled = true; app.setStatus("No earnings data available (ETF/index?)"); return; } app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const state = &app.states.earnings; // wall-clock required: per-frame "now" for the earnings // "data Xs ago" readout. Captured here so the pure renderer below // stays free of io. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); return renderEarningsLines(arena, app.theme, app.symbol, state.disabled, state.data, state.timestamp, state.error_msg, now_s); } /// Render earnings tab content. Pure function — no App dependency. /// /// `now_s` is the unix-epoch-seconds reference point for the /// "data Xs ago" age readout. Caller captures it once per frame via /// `std.Io.Timestamp.now(io, .real).toSeconds()` and passes it in. pub fn renderEarningsLines( arena: std.mem.Allocator, th: theme.Theme, symbol: []const u8, earnings_disabled: bool, earnings_data: ?[]const zfin.EarningsEvent, earnings_timestamp: i64, earnings_error: ?[]const u8, now_s: i64, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); if (symbol.len == 0) { try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } if (earnings_disabled) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var earn_ago_buf: [16]u8 = undefined; const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp, now_s); if (earn_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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() }); } return lines.toOwnedSlice(arena); }; if (ev.len == 0) { try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", }), .style = th.mutedStyle() }); for (ev) |e| { var row_buf: [128]u8 = undefined; const row = fmt.fmtEarningsRow(&row_buf, e); const text = try std.fmt.allocPrint(arena, " {s}", .{row.text}); const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = text, .style = row_style }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; test "renderEarningsLines with earnings data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; const events = [_]zfin.EarningsEvent{.{ .symbol = "AAPL", .date = try zfin.Date.parse("2025-01-15"), .quarter = 4, .estimate = 1.50, .actual = 1.65, }}; const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0, null, 1_700_000_000); // 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); try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null); // Data row should contain the date try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null); } test "renderEarningsLines no symbol" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; const lines = try renderEarningsLines(arena, th, "", false, null, 0, null, 1_700_000_000); try testing.expectEqual(@as(usize, 2), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null); } test "renderEarningsLines disabled" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0, null, 1_700_000_000); try testing.expectEqual(@as(usize, 2), lines.len); try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null); } test "renderEarningsLines no data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme.default_theme; const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, null, 1_700_000_000); 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.default_theme; const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FMP_API_KEY", 1_700_000_000); try testing.expectEqual(@as(usize, 4), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null); } test "tab.init / deinit are idempotent" { var state: State = undefined; var dummy_app: tui.App = undefined; // intentionally undefined: init/deinit // for earnings don't touch app. try tab.init(&state, &dummy_app); // After init, state should be defaulted. try testing.expectEqual(false, state.loaded); try testing.expectEqual(false, state.disabled); try testing.expect(state.data == null); try testing.expect(state.error_msg == null); // deinit on a default state should be safe (no-op-ish). // We can't fully exercise deinit because app.allocator isn't // initialized; the `if (state.data) |e|` branch is what'd // require the allocator, and `data` is null here. So this // verifies the no-allocation deinit path. tab.deinit(&state, &dummy_app); try testing.expect(state.data == null); }