migrate earnings tab to new framework
This commit is contained in:
parent
2ed34e6c10
commit
b6372a33de
2 changed files with 356 additions and 36 deletions
230
src/tui.zig
230
src/tui.zig
|
|
@ -302,6 +302,197 @@ pub const ChartState = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// Per-tab state, owned by `App` and accessed as `app.states.<tab>`.
|
||||
///
|
||||
/// Each tab module defines its own `State` struct (see
|
||||
/// `src/tui/tab_framework.zig` for the contract). This aggregator
|
||||
/// holds one of each — adding a tab means adding one field here and
|
||||
/// one entry in the `tab_modules` registry below.
|
||||
///
|
||||
/// Migration in progress: tabs are being moved off App's flat field
|
||||
/// pile into per-tab State structs one tab at a time. Tabs not yet
|
||||
/// migrated still have their fields directly on `App`. See
|
||||
/// `TODO.md`'s "Refactor: TUI tab framework" entry.
|
||||
pub const TabStates = struct {
|
||||
earnings: earnings_tab.State = .{},
|
||||
};
|
||||
|
||||
/// Comptime registry of all tab modules conforming to the
|
||||
/// framework contract. Each entry pairs a registry name with the
|
||||
/// imported tab module. The validator below walks this list at
|
||||
/// comptime and asserts every entry conforms to the contract;
|
||||
/// missing or wrong-shape decls produce build errors with the
|
||||
/// full expected signature.
|
||||
///
|
||||
/// **This is the only place the validator is invoked.** Tab
|
||||
/// modules don't have to opt in individually — adding a tab here
|
||||
/// is the same act as registering it for validation. If you forget
|
||||
/// to add a tab here, you'd also forget the dispatcher wiring (the
|
||||
/// step-3 walker will iterate this same list), so a missed entry
|
||||
/// is loud at integration time.
|
||||
///
|
||||
/// As tabs migrate to the framework, add them here. Pre-migration
|
||||
/// tabs (portfolio, history, projections) live outside the
|
||||
/// registry and are dispatched ad-hoc until they migrate.
|
||||
///
|
||||
/// **TODO: this explicit registry is transitional.** Once all tabs
|
||||
/// have migrated AND the dispatcher walker (step 3) lands, the
|
||||
/// goal is for adding a tab to `tui.zig` (the `_tab` import + the
|
||||
/// `TabStates` field + the `Tab` enum variant) to *automatically*
|
||||
/// validate it — no separate `tab_modules` literal to keep in
|
||||
/// sync. The likely shape: a comptime walk over `Tab` enum
|
||||
/// variants that resolves each to its `_tab` module via a name
|
||||
/// convention, eliminating the parallel registry. The current
|
||||
/// list-based form is a stopgap while the framework is being
|
||||
/// proven out tab-by-tab.
|
||||
const tab_modules = .{
|
||||
.earnings = earnings_tab,
|
||||
};
|
||||
|
||||
comptime {
|
||||
for (std.meta.fields(@TypeOf(tab_modules))) |field| {
|
||||
const Module = @field(tab_modules, field.name);
|
||||
tab_framework.validateTabModule(Module);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-symbol fetched data. Owned by `App` and accessed as
|
||||
/// `app.symbol_data.*`. Populated by whichever tab fetches first
|
||||
/// (typically the perf or quote tab); consumed by every tab that
|
||||
/// renders symbol-bound information (quote, perf, options, earnings).
|
||||
///
|
||||
/// Distinct from "tab-private state" in `app.states` because a
|
||||
/// single tab doesn't own this data — it's a shared cache scoped
|
||||
/// to "the current symbol." Cleared in `resetSymbolData` whenever
|
||||
/// the user changes symbols.
|
||||
pub const SymbolData = struct {
|
||||
/// Daily OHLCV candles for the active symbol, oldest-first.
|
||||
/// Owned by SymbolData; freed via `deinit` or `clear`.
|
||||
candles: ?[]zfin.Candle = null,
|
||||
/// Unix-epoch seconds for the candle fetch — drives the
|
||||
/// "data Xs ago" header readout.
|
||||
candle_timestamp: i64 = 0,
|
||||
/// Dividend events. Owned by SymbolData; freed via `deinit`
|
||||
/// or `clear`.
|
||||
dividends: ?[]zfin.Dividend = null,
|
||||
/// Trailing risk metrics (volatility, sharpe, max drawdown)
|
||||
/// computed from the candle series.
|
||||
risk_metrics: ?zfin.risk.TrailingRisk = null,
|
||||
/// Trailing returns at the candle endpoint date (price-only
|
||||
/// vs total-return — total requires dividend data).
|
||||
trailing_price: ?zfin.performance.TrailingReturns = null,
|
||||
trailing_total: ?zfin.performance.TrailingReturns = null,
|
||||
/// Trailing returns at the most recent month-end (for
|
||||
/// Morningstar-style reporting).
|
||||
trailing_me_price: ?zfin.performance.TrailingReturns = null,
|
||||
trailing_me_total: ?zfin.performance.TrailingReturns = null,
|
||||
/// ETF profile (holdings, sectors). Loaded lazily on perf tab
|
||||
/// activation; null for non-ETFs. The `etf_loaded` flag means
|
||||
/// "we attempted the load" so we don't retry every activation.
|
||||
etf_profile: ?zfin.EtfProfile = null,
|
||||
etf_loaded: bool = false,
|
||||
|
||||
/// Free all owned slices. Idempotent — safe to call after
|
||||
/// partial-load failure or repeated.
|
||||
pub fn deinit(self: *SymbolData, allocator: std.mem.Allocator) void {
|
||||
self.clear(allocator);
|
||||
}
|
||||
|
||||
/// Free all owned slices and reset to defaults. Used on symbol
|
||||
/// change so the next fetch starts from a clean slate.
|
||||
pub fn clear(self: *SymbolData, allocator: std.mem.Allocator) void {
|
||||
if (self.candles) |c| allocator.free(c);
|
||||
if (self.dividends) |d| zfin.Dividend.freeSlice(allocator, d);
|
||||
if (self.etf_profile) |profile| {
|
||||
if (profile.holdings) |h| {
|
||||
for (h) |holding| {
|
||||
if (holding.symbol) |s| allocator.free(s);
|
||||
allocator.free(holding.name);
|
||||
}
|
||||
allocator.free(h);
|
||||
}
|
||||
if (profile.sectors) |s| {
|
||||
for (s) |sec| allocator.free(sec.name);
|
||||
allocator.free(s);
|
||||
}
|
||||
}
|
||||
self.* = .{};
|
||||
}
|
||||
|
||||
// ── Derived projections of `candles` ──────────────────────
|
||||
//
|
||||
// These are functions, not fields, so they can't drift from
|
||||
// the underlying `candles` slice. Renderers call them per-frame;
|
||||
// the cost is a single deref + (for first/last) a slice index.
|
||||
|
||||
/// Number of cached candles. Zero when candles haven't loaded.
|
||||
pub fn candleCount(self: *const SymbolData) usize {
|
||||
return if (self.candles) |c| c.len else 0;
|
||||
}
|
||||
|
||||
/// First (oldest) candle date, or null if no candles.
|
||||
pub fn candleFirstDate(self: *const SymbolData) ?zfin.Date {
|
||||
const c = self.candles orelse return null;
|
||||
if (c.len == 0) return null;
|
||||
return c[0].date;
|
||||
}
|
||||
|
||||
/// Last (newest) candle date, or null if no candles.
|
||||
pub fn candleLastDate(self: *const SymbolData) ?zfin.Date {
|
||||
const c = self.candles orelse return null;
|
||||
if (c.len == 0) return null;
|
||||
return c[c.len - 1].date;
|
||||
}
|
||||
};
|
||||
|
||||
/// Per-portfolio shared data. Owned by `App` and accessed as
|
||||
/// `app.portfolio.*`. Populated by `loadPortfolioData`; consumed
|
||||
/// by every tab that reads portfolio-bound information (portfolio,
|
||||
/// projections, history, analysis).
|
||||
///
|
||||
/// Distinct from "tab-private state" in `app.states` because a
|
||||
/// single tab doesn't own this data — it's a shared cache scoped
|
||||
/// to "the current portfolio file."
|
||||
pub const PortfolioData = struct {
|
||||
/// Parsed portfolio.srf (lots, watchlist, classifications).
|
||||
/// The "portfolio" everyone refers to. Owned here; freed via
|
||||
/// `deinit`.
|
||||
file: ?zfin.Portfolio = null,
|
||||
/// Computed summary (allocations, totals, gain/loss). Derived
|
||||
/// from `file` + per-symbol prices. Refreshed on price updates.
|
||||
summary: ?zfin.valuation.PortfolioSummary = null,
|
||||
/// Whether the portfolio is loaded into `file`. Distinct from
|
||||
/// `file != null` because the load may have failed and we
|
||||
/// want to remember "we tried."
|
||||
loaded: bool = false,
|
||||
/// Historical snapshot values (1W/1M/1Q/1Y/3Y/5Y/10Y) for the
|
||||
/// portfolio's value-over-time view. Populated on portfolio
|
||||
/// load; null until then.
|
||||
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
|
||||
/// Account-tax-type metadata loaded from `accounts.srf` next
|
||||
/// to the portfolio. Used by analysis (tax-type breakdown)
|
||||
/// and portfolio (per-account display).
|
||||
account_map: ?zfin.analysis.AccountMap = null,
|
||||
/// Cached prices for watchlist symbols (no live fetching during
|
||||
/// render). Populated on portfolio load and refresh.
|
||||
watchlist_prices: ?std.StringHashMap(f64) = null,
|
||||
/// Most recent quote date across the portfolio's held symbols
|
||||
/// (max of each symbol's last cached candle date). Drives the
|
||||
/// "as of close on YYYY-MM-DD" line under the portfolio totals.
|
||||
/// Computed as a side effect of the portfolio-prices loop in
|
||||
/// `portfolio_tab.loadPortfolioData`; null when no symbols have
|
||||
/// cached candles.
|
||||
latest_quote_date: ?zfin.Date = null,
|
||||
|
||||
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
|
||||
if (self.summary) |*s| s.deinit(allocator);
|
||||
if (self.account_map) |*am| am.deinit();
|
||||
if (self.watchlist_prices) |*wp| wp.deinit();
|
||||
if (self.file) |*pf| pf.deinit();
|
||||
self.* = .{};
|
||||
}
|
||||
};
|
||||
|
||||
/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
|
||||
/// interface via `widget()`, which wires `typeErasedEventHandler` and
|
||||
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
|
||||
|
|
@ -315,6 +506,11 @@ pub const ChartState = struct {
|
|||
pub const App = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
/// Per-tab private state. See `TabStates` above. Tabs that have
|
||||
/// migrated to the framework own their fields under
|
||||
/// `app.states.<tab>`; tabs not yet migrated still have their
|
||||
/// fields directly on App.
|
||||
states: TabStates = .{},
|
||||
/// Captured at App init and refreshed at tab change. Using a cached
|
||||
/// date (rather than calling the clock on every render) keeps render
|
||||
/// deterministic within a single frame and avoids threading `io`
|
||||
|
|
@ -385,7 +581,6 @@ pub const App = struct {
|
|||
// Cached data for rendering
|
||||
candles: ?[]zfin.Candle = null,
|
||||
dividends: ?[]zfin.Dividend = null,
|
||||
earnings_data: ?[]zfin.EarningsEvent = null,
|
||||
options_data: ?[]zfin.OptionsChain = null,
|
||||
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
|
||||
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = null,
|
||||
|
|
@ -399,19 +594,14 @@ pub const App = struct {
|
|||
candle_last_date: ?zfin.Date = null,
|
||||
data_error: ?[]const u8 = null,
|
||||
perf_loaded: bool = false,
|
||||
earnings_loaded: bool = false,
|
||||
options_loaded: bool = false,
|
||||
portfolio_loaded: bool = false,
|
||||
// Data timestamps (unix seconds)
|
||||
candle_timestamp: i64 = 0,
|
||||
options_timestamp: i64 = 0,
|
||||
earnings_timestamp: i64 = 0,
|
||||
// Stored real-time quote (only fetched on manual refresh)
|
||||
quote: ?zfin.Quote = null,
|
||||
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,
|
||||
|
|
@ -1693,9 +1883,6 @@ pub const App = struct {
|
|||
|
||||
fn resetSymbolData(self: *App) void {
|
||||
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;
|
||||
|
|
@ -1705,12 +1892,10 @@ pub const App = struct {
|
|||
self.options_rows.clearRetainingCapacity();
|
||||
self.candle_timestamp = 0;
|
||||
self.options_timestamp = 0;
|
||||
self.earnings_timestamp = 0;
|
||||
self.quote = null;
|
||||
self.quote_timestamp = 0;
|
||||
self.freeCandles();
|
||||
self.freeDividends();
|
||||
self.freeEarnings();
|
||||
self.freeOptions();
|
||||
self.freeEtfProfile();
|
||||
self.trailing_price = null;
|
||||
|
|
@ -1718,6 +1903,12 @@ pub const App = struct {
|
|||
self.trailing_me_price = null;
|
||||
self.trailing_me_total = null;
|
||||
self.risk_metrics = null;
|
||||
// Tab-private symbol-bound state is dropped via each tab's
|
||||
// onSymbolChange hook (where defined). Distinct from
|
||||
// `tab.deinit` (App teardown) and `tab.reload` (drops AND
|
||||
// re-fetches) — these hooks just drop the cache; the next
|
||||
// `activate` will re-fetch lazily.
|
||||
earnings_tab.tab.onSymbolChange(&self.states.earnings, self);
|
||||
self.scroll_offset = 0;
|
||||
self.chart.dirty = true;
|
||||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
||||
|
|
@ -1753,10 +1944,7 @@ pub const App = struct {
|
|||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
||||
},
|
||||
.earnings => {
|
||||
self.earnings_loaded = false;
|
||||
self.earnings_disabled = false;
|
||||
self.earnings_error = null;
|
||||
self.freeEarnings();
|
||||
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
|
||||
},
|
||||
.options => {
|
||||
self.options_loaded = false;
|
||||
|
|
@ -1809,8 +1997,7 @@ pub const App = struct {
|
|||
},
|
||||
.earnings => {
|
||||
if (self.symbol.len == 0) return;
|
||||
if (self.earnings_disabled) return;
|
||||
if (!self.earnings_loaded) earnings_tab.loadData(self);
|
||||
earnings_tab.tab.activate(&self.states.earnings, self) catch {};
|
||||
},
|
||||
.options => {
|
||||
if (self.symbol.len == 0) return;
|
||||
|
|
@ -1864,11 +2051,6 @@ pub const App = struct {
|
|||
self.dividends = null;
|
||||
}
|
||||
|
||||
pub fn freeEarnings(self: *App) void {
|
||||
if (self.earnings_data) |e| self.allocator.free(e);
|
||||
self.earnings_data = null;
|
||||
}
|
||||
|
||||
pub fn freeOptions(self: *App) void {
|
||||
if (self.options_data) |chains| {
|
||||
zfin.OptionsChain.freeSlice(self.allocator, chains);
|
||||
|
|
@ -1909,10 +2091,10 @@ pub const App = struct {
|
|||
fn deinitData(self: *App) void {
|
||||
self.freeCandles();
|
||||
self.freeDividends();
|
||||
self.freeEarnings();
|
||||
self.freeOptions();
|
||||
self.freeEtfProfile();
|
||||
self.freePortfolioSummary();
|
||||
earnings_tab.tab.deinit(&self.states.earnings, self);
|
||||
self.freePreparedSections();
|
||||
self.portfolio_rows.deinit(self.allocator);
|
||||
self.options_rows.deinit(self.allocator);
|
||||
|
|
@ -2004,7 +2186,7 @@ pub const App = struct {
|
|||
}
|
||||
|
||||
fn isTabDisabled(self: *App, t: Tab) bool {
|
||||
return (t == .earnings and self.earnings_disabled) or
|
||||
return (t == .earnings and earnings_tab.tab.isDisabled(self)) or
|
||||
(t == .analysis and self.analysis_disabled) or
|
||||
(t == .history and self.history_disabled) or
|
||||
(t == .projections and self.projections_disabled);
|
||||
|
|
|
|||
|
|
@ -4,35 +4,151 @@ 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;
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
// ── 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 fn loadData(app: *App) void {
|
||||
app.earnings_loaded = true;
|
||||
app.earnings_error = null;
|
||||
app.freeEarnings();
|
||||
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;
|
||||
|
||||
/// 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 => {
|
||||
app.earnings_error = "No API key. Set FMP_API_KEY (free at financialmodelingprep.com)";
|
||||
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 => {
|
||||
app.earnings_disabled = true;
|
||||
state.disabled = true;
|
||||
app.setStatus("No earnings data (ETF/index?)");
|
||||
},
|
||||
else => {
|
||||
app.earnings_error = "Error loading earnings data. Press r to retry.";
|
||||
state.error_msg = "Error loading earnings data. Press r to retry.";
|
||||
app.setStatus("Error loading earnings");
|
||||
},
|
||||
}
|
||||
return;
|
||||
};
|
||||
app.earnings_data = result.data;
|
||||
app.earnings_timestamp = result.timestamp;
|
||||
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
|
||||
|
|
@ -46,7 +162,7 @@ pub fn loadData(app: *App) void {
|
|||
}
|
||||
|
||||
if (result.data.len == 0) {
|
||||
app.earnings_disabled = true;
|
||||
state.disabled = true;
|
||||
app.setStatus("No earnings data available (ETF/index?)");
|
||||
return;
|
||||
}
|
||||
|
|
@ -56,11 +172,12 @@ pub fn loadData(app: *App) void {
|
|||
// ── 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, app.earnings_disabled, app.earnings_data, app.earnings_timestamp, app.earnings_error, now_s);
|
||||
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.
|
||||
|
|
@ -202,3 +319,24 @@ test "renderEarningsLines with error message" {
|
|||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue