diff --git a/src/tui.zig b/src/tui.zig index 361a9b1..8a6cc28 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -302,6 +302,197 @@ pub const ChartState = struct { } }; +/// Per-tab state, owned by `App` and accessed as `app.states.`. +/// +/// 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.`; 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); diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 92b5844..6fb58d9 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -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); +}