345 lines
14 KiB
Zig
345 lines
14 KiB
Zig
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);
|
|
}
|