migrate earnings tab to new framework
This commit is contained in:
parent
2431f6a6b1
commit
4ffddb44a2
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`
|
/// Root widget for the interactive TUI. Implements the vaxis `vxfw.Widget`
|
||||||
/// interface via `widget()`, which wires `typeErasedEventHandler` and
|
/// interface via `widget()`, which wires `typeErasedEventHandler` and
|
||||||
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
|
/// `typeErasedDrawFn` as callbacks. Passed to `vxfw.App.run()` as the
|
||||||
|
|
@ -315,6 +506,11 @@ pub const ChartState = struct {
|
||||||
pub const App = struct {
|
pub const App = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
io: std.Io,
|
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
|
/// Captured at App init and refreshed at tab change. Using a cached
|
||||||
/// date (rather than calling the clock on every render) keeps render
|
/// date (rather than calling the clock on every render) keeps render
|
||||||
/// deterministic within a single frame and avoids threading `io`
|
/// deterministic within a single frame and avoids threading `io`
|
||||||
|
|
@ -385,7 +581,6 @@ pub const App = struct {
|
||||||
// Cached data for rendering
|
// Cached data for rendering
|
||||||
candles: ?[]zfin.Candle = null,
|
candles: ?[]zfin.Candle = null,
|
||||||
dividends: ?[]zfin.Dividend = null,
|
dividends: ?[]zfin.Dividend = null,
|
||||||
earnings_data: ?[]zfin.EarningsEvent = null,
|
|
||||||
options_data: ?[]zfin.OptionsChain = null,
|
options_data: ?[]zfin.OptionsChain = null,
|
||||||
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
|
portfolio_summary: ?zfin.valuation.PortfolioSummary = null,
|
||||||
historical_snapshots: ?[zfin.valuation.HistoricalPeriod.all.len]zfin.valuation.HistoricalSnapshot = 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,
|
candle_last_date: ?zfin.Date = null,
|
||||||
data_error: ?[]const u8 = null,
|
data_error: ?[]const u8 = null,
|
||||||
perf_loaded: bool = false,
|
perf_loaded: bool = false,
|
||||||
earnings_loaded: bool = false,
|
|
||||||
options_loaded: bool = false,
|
options_loaded: bool = false,
|
||||||
portfolio_loaded: bool = false,
|
portfolio_loaded: bool = false,
|
||||||
// Data timestamps (unix seconds)
|
// Data timestamps (unix seconds)
|
||||||
candle_timestamp: i64 = 0,
|
candle_timestamp: i64 = 0,
|
||||||
options_timestamp: i64 = 0,
|
options_timestamp: i64 = 0,
|
||||||
earnings_timestamp: i64 = 0,
|
|
||||||
// Stored real-time quote (only fetched on manual refresh)
|
// Stored real-time quote (only fetched on manual refresh)
|
||||||
quote: ?zfin.Quote = null,
|
quote: ?zfin.Quote = null,
|
||||||
quote_timestamp: i64 = 0,
|
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 (loaded lazily on quote tab)
|
||||||
etf_profile: ?zfin.EtfProfile = null,
|
etf_profile: ?zfin.EtfProfile = null,
|
||||||
etf_loaded: bool = false,
|
etf_loaded: bool = false,
|
||||||
|
|
@ -1693,9 +1883,6 @@ pub const App = struct {
|
||||||
|
|
||||||
fn resetSymbolData(self: *App) void {
|
fn resetSymbolData(self: *App) void {
|
||||||
self.perf_loaded = false;
|
self.perf_loaded = false;
|
||||||
self.earnings_loaded = 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;
|
||||||
|
|
@ -1705,12 +1892,10 @@ pub const App = struct {
|
||||||
self.options_rows.clearRetainingCapacity();
|
self.options_rows.clearRetainingCapacity();
|
||||||
self.candle_timestamp = 0;
|
self.candle_timestamp = 0;
|
||||||
self.options_timestamp = 0;
|
self.options_timestamp = 0;
|
||||||
self.earnings_timestamp = 0;
|
|
||||||
self.quote = null;
|
self.quote = null;
|
||||||
self.quote_timestamp = 0;
|
self.quote_timestamp = 0;
|
||||||
self.freeCandles();
|
self.freeCandles();
|
||||||
self.freeDividends();
|
self.freeDividends();
|
||||||
self.freeEarnings();
|
|
||||||
self.freeOptions();
|
self.freeOptions();
|
||||||
self.freeEtfProfile();
|
self.freeEtfProfile();
|
||||||
self.trailing_price = null;
|
self.trailing_price = null;
|
||||||
|
|
@ -1718,6 +1903,12 @@ pub const App = struct {
|
||||||
self.trailing_me_price = null;
|
self.trailing_me_price = null;
|
||||||
self.trailing_me_total = null;
|
self.trailing_me_total = null;
|
||||||
self.risk_metrics = 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.scroll_offset = 0;
|
||||||
self.chart.dirty = true;
|
self.chart.dirty = true;
|
||||||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
||||||
|
|
@ -1753,10 +1944,7 @@ pub const App = struct {
|
||||||
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
self.chart.freeCache(self.allocator); // Invalidate indicator cache
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
self.earnings_loaded = false;
|
earnings_tab.tab.reload(&self.states.earnings, self) catch {};
|
||||||
self.earnings_disabled = false;
|
|
||||||
self.earnings_error = null;
|
|
||||||
self.freeEarnings();
|
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
self.options_loaded = false;
|
self.options_loaded = false;
|
||||||
|
|
@ -1809,8 +1997,7 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
if (self.symbol.len == 0) return;
|
if (self.symbol.len == 0) return;
|
||||||
if (self.earnings_disabled) return;
|
earnings_tab.tab.activate(&self.states.earnings, self) catch {};
|
||||||
if (!self.earnings_loaded) earnings_tab.loadData(self);
|
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
if (self.symbol.len == 0) return;
|
if (self.symbol.len == 0) return;
|
||||||
|
|
@ -1864,11 +2051,6 @@ pub const App = struct {
|
||||||
self.dividends = null;
|
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 {
|
pub fn freeOptions(self: *App) void {
|
||||||
if (self.options_data) |chains| {
|
if (self.options_data) |chains| {
|
||||||
zfin.OptionsChain.freeSlice(self.allocator, chains);
|
zfin.OptionsChain.freeSlice(self.allocator, chains);
|
||||||
|
|
@ -1909,10 +2091,10 @@ pub const App = struct {
|
||||||
fn deinitData(self: *App) void {
|
fn deinitData(self: *App) void {
|
||||||
self.freeCandles();
|
self.freeCandles();
|
||||||
self.freeDividends();
|
self.freeDividends();
|
||||||
self.freeEarnings();
|
|
||||||
self.freeOptions();
|
self.freeOptions();
|
||||||
self.freeEtfProfile();
|
self.freeEtfProfile();
|
||||||
self.freePortfolioSummary();
|
self.freePortfolioSummary();
|
||||||
|
earnings_tab.tab.deinit(&self.states.earnings, self);
|
||||||
self.freePreparedSections();
|
self.freePreparedSections();
|
||||||
self.portfolio_rows.deinit(self.allocator);
|
self.portfolio_rows.deinit(self.allocator);
|
||||||
self.options_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 {
|
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 == .analysis and self.analysis_disabled) or
|
||||||
(t == .history and self.history_disabled) or
|
(t == .history and self.history_disabled) or
|
||||||
(t == .projections and self.projections_disabled);
|
(t == .projections and self.projections_disabled);
|
||||||
|
|
|
||||||
|
|
@ -4,35 +4,151 @@ const zfin = @import("../root.zig");
|
||||||
const fmt = @import("../format.zig");
|
const fmt = @import("../format.zig");
|
||||||
const theme = @import("theme.zig");
|
const theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
|
const framework = @import("tab_framework.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
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 {
|
pub const Action = enum {};
|
||||||
app.earnings_loaded = true;
|
|
||||||
app.earnings_error = null;
|
// ── Tab-private state ─────────────────────────────────────────
|
||||||
app.freeEarnings();
|
|
||||||
|
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| {
|
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
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");
|
app.setStatus("No API key. Set FMP_API_KEY");
|
||||||
},
|
},
|
||||||
zfin.DataError.FetchFailed => {
|
zfin.DataError.FetchFailed => {
|
||||||
app.earnings_disabled = true;
|
state.disabled = true;
|
||||||
app.setStatus("No earnings data (ETF/index?)");
|
app.setStatus("No earnings data (ETF/index?)");
|
||||||
},
|
},
|
||||||
else => {
|
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");
|
app.setStatus("Error loading earnings");
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
app.earnings_data = result.data;
|
state.data = result.data;
|
||||||
app.earnings_timestamp = result.timestamp;
|
state.timestamp = result.timestamp;
|
||||||
|
|
||||||
// Sort newest-first — this is what users expect on earnings tables
|
// Sort newest-first — this is what users expect on earnings tables
|
||||||
// everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant
|
// everywhere (Yahoo, Morningstar, etc.) and keeps the most relevant
|
||||||
|
|
@ -46,7 +162,7 @@ pub fn loadData(app: *App) void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.len == 0) {
|
if (result.data.len == 0) {
|
||||||
app.earnings_disabled = true;
|
state.disabled = true;
|
||||||
app.setStatus("No earnings data available (ETF/index?)");
|
app.setStatus("No earnings data available (ETF/index?)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -56,11 +172,12 @@ 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 {
|
||||||
|
const state = &app.states.earnings;
|
||||||
// wall-clock required: per-frame "now" for the earnings
|
// wall-clock required: per-frame "now" for the earnings
|
||||||
// "data Xs ago" readout. Captured here so the pure renderer below
|
// "data Xs ago" readout. Captured here so the pure renderer below
|
||||||
// stays free of io.
|
// stays free of io.
|
||||||
const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
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.
|
/// 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.expectEqual(@as(usize, 4), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FMP_API_KEY") != null);
|
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