From dac310e38e31a4c660c23dbda9a6784812549617 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 23 Apr 2026 00:32:18 -0700 Subject: [PATCH] initial tui history implementation --- src/tui.zig | 68 ++++++-- src/tui/history_tab.zig | 378 ++++++++++++++++++++++++++++++++++++++++ src/tui/keybinds.zig | 4 + 3 files changed, 436 insertions(+), 14 deletions(-) create mode 100644 src/tui/history_tab.zig diff --git a/src/tui.zig b/src/tui.zig index f21ce6a..9695b92 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -13,6 +13,9 @@ const perf_tab = @import("tui/perf_tab.zig"); const options_tab = @import("tui/options_tab.zig"); const earnings_tab = @import("tui/earnings_tab.zig"); const analysis_tab = @import("tui/analysis_tab.zig"); +const history_tab = @import("tui/history_tab.zig"); +const history_io = @import("history.zig"); +const timeline_mod = @import("analytics/timeline.zig"); /// Comptime-generated table of single-character grapheme slices with static lifetime. /// This avoids dangling pointers from stack-allocated temporaries in draw functions. @@ -75,6 +78,7 @@ pub const Tab = enum { options, earnings, analysis, + history, fn label(self: Tab) []const u8 { return switch (self) { @@ -84,11 +88,12 @@ pub const Tab = enum { .options => " 4:Options ", .earnings => " 5:Earnings ", .analysis => " 6:Analysis ", + .history => " 7:History ", }; } }; -const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis }; +const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history }; pub const InputMode = enum { normal, @@ -369,6 +374,13 @@ pub const App = struct { classification_map: ?zfin.classification.ClassificationMap = null, account_map: ?zfin.analysis.AccountMap = null, + // History tab state + history_loaded: bool = false, + history_disabled: bool = false, // true when no portfolio path (history requires it) + history_load_result: ?history_io.LoadedHistory = null, + history_series: ?timeline_mod.TimelineSeries = null, + history_metric: timeline_mod.Metric = .net_worth, + // Mouse wheel debounce for cursor-based tabs (portfolio, options). // Terminals often send multiple wheel events per physical tick. last_wheel_ns: i128 = 0, @@ -906,7 +918,7 @@ pub const App = struct { ctx.queueRefresh() catch {}; return ctx.consumeAndRedraw(); }, - .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6 => { + .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7 => { const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); if (idx < tabs.len) { const target = tabs[idx]; @@ -1018,6 +1030,12 @@ pub const App = struct { return ctx.consumeAndRedraw(); } }, + .history_metric_next => { + if (self.active_tab == .history) { + history_tab.cycleMetric(self); + return ctx.consumeAndRedraw(); + } + }, .sort_col_next => { if (self.active_tab == .portfolio) { if (self.portfolio_sort_field.next()) |new_field| { @@ -1333,7 +1351,7 @@ pub const App = struct { .options => { self.svc.invalidate(self.symbol, .options); }, - .portfolio, .analysis => {}, + .portfolio, .analysis, .history => {}, } } switch (self.active_tab) { @@ -1365,6 +1383,10 @@ pub const App = struct { if (self.account_map) |*am| am.deinit(); self.account_map = null; }, + .history => { + self.history_loaded = false; + history_tab.freeLoaded(self); + }, } self.loadTabData(); @@ -1405,6 +1427,10 @@ pub const App = struct { if (self.analysis_disabled) return; if (!self.analysis_loaded) self.loadAnalysisData(); }, + .history => { + if (self.history_disabled) return; + if (!self.history_loaded) history_tab.loadData(self); + }, } } @@ -1508,6 +1534,7 @@ pub const App = struct { if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.classification_map) |*cm| cm.deinit(); if (self.account_map) |*am| am.deinit(); + history_tab.freeLoaded(self); self.chart.freeCache(self.allocator); // Free cached indicators } @@ -1585,7 +1612,8 @@ pub const App = struct { fn isTabDisabled(self: *App, t: Tab) bool { return (t == .earnings and self.earnings_disabled) or - (t == .analysis and self.analysis_disabled); + (t == .analysis and self.analysis_disabled) or + (t == .history and self.history_disabled); } fn isSymbolSelected(self: *App) bool { @@ -1615,6 +1643,7 @@ pub const App = struct { .options => try self.drawOptionsContent(ctx.arena, buf, width, height), .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), .analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)), + .history => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHistoryStyledLines(ctx.arena)), } } @@ -1763,6 +1792,10 @@ pub const App = struct { return analysis_tab.buildStyledLines(self, arena); } + fn buildHistoryStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + return history_tab.buildStyledLines(self, arena); + } + // ── Help ───────────────────────────────────────────────────── fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -1775,16 +1808,17 @@ pub const App = struct { const actions = comptime std.enums.values(keybinds.Action); const action_labels = [_][]const u8{ - "Quit", "Refresh", "Previous tab", "Next tab", - "Tab 1", "Tab 2", "Tab 3", "Tab 4", - "Tab 5", "Tab 6", "Scroll down", "Scroll up", - "Scroll to top", "Scroll to bottom", "Page down", "Page up", - "Select next", "Select prev", "Expand/collapse", "Select symbol", - "Change symbol (search)", "This help", "Reload portfolio from disk", "Toggle all calls (options)", - "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", - "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM", - "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe", - "Sort: next column", "Sort: prev column", "Sort: reverse order", "Account filter (portfolio)", + "Quit", "Refresh", "Previous tab", "Next tab", + "Tab 1", "Tab 2", "Tab 3", "Tab 4", + "Tab 5", "Tab 6", "Tab 7", "Scroll down", + "Scroll up", "Scroll to top", "Scroll to bottom", "Page down", + "Page up", "Select next", "Select prev", "Expand/collapse", + "Select symbol", "Change symbol (search)", "This help", "Reload portfolio from disk", + "Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", + "Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", + "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", + "Chart: prev timeframe", "History: cycle metric", "Sort: next column", "Sort: prev column", + "Sort: reverse order", "Account filter (portfolio)", }; for (actions, 0..) |action, ai| { @@ -1988,6 +2022,7 @@ comptime { _ = options_tab; _ = earnings_tab; _ = analysis_tab; + _ = history_tab; } /// Entry point for the interactive TUI. @@ -2119,6 +2154,11 @@ pub fn run( if (app_inst.portfolio == null) { app_inst.analysis_disabled = true; } + // History tab also requires a portfolio path to locate the + // history/ subdirectory. + if (app_inst.portfolio_path == null) { + app_inst.history_disabled = true; + } // Pre-fetch portfolio prices before TUI starts, with stderr progress. // This runs while the terminal is still in normal mode so output is visible. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig new file mode 100644 index 0000000..b04a46c --- /dev/null +++ b/src/tui/history_tab.zig @@ -0,0 +1,378 @@ +//! TUI history tab — portfolio value timeline over time. +//! +//! Consumes `src/analytics/timeline.zig` (pure compute) and +//! `src/history.zig` (snapshot IO). No analytics live here: this module +//! is only responsible for driving data loading on tab activation and +//! converting the timeline series into `StyledLine`s for rendering. +//! +//! Shows: +//! - Headline summary (first/last/Δ for net worth / liquid / illiquid) +//! - Braille timeline chart for the selected metric +//! - Recent snapshots table (last N entries with per-metric deltas) +//! +//! The chart metric is cycleable via the `history_metric_next` keybind +//! (default `m`). The selected metric persists on `App.history_metric`. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const fmt = @import("../format.zig"); +const theme_mod = @import("theme.zig"); +const tui = @import("../tui.zig"); +const history_io = @import("../history.zig"); +const timeline = @import("../analytics/timeline.zig"); +const App = tui.App; +const StyledLine = tui.StyledLine; + +// Show at most this many rows in the bottom table. Older rows still +// contribute to the chart and summary, just not to the table. +const max_table_rows: usize = 20; + +// ── Data loading ────────────────────────────────────────────── + +pub fn loadData(app: *App) void { + app.history_loaded = true; + + // Clear any previous load (refresh path). + freeLoaded(app); + + const portfolio_path = app.portfolio_path orelse { + app.setStatus("History tab requires a loaded portfolio"); + return; + }; + + const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx else { + app.setStatus("Cannot derive history/ from portfolio path"); + return; + }; + const portfolio_dir = portfolio_path[0..dir_end]; + + const history_dir = std.fs.path.join(app.allocator, &.{ portfolio_dir, "history" }) catch return; + defer app.allocator.free(history_dir); + + var loaded = history_io.loadHistoryDir(app.allocator, history_dir) catch { + app.setStatus("Failed to read history/ directory"); + return; + }; + errdefer loaded.deinit(); + + if (loaded.snapshots.len == 0) { + loaded.deinit(); + app.setStatus("No snapshots in history/ (run: zfin snapshot)"); + return; + } + + var series = timeline.buildSeries(app.allocator, loaded.snapshots) catch { + loaded.deinit(); + app.setStatus("Failed to build timeline series"); + return; + }; + + // Commit + app.history_load_result = loaded; + app.history_series = series; + _ = &series; // keep mutable reference (not needed once committed) +} + +/// Free both the snapshot bytes and the timeline series. Caller must +/// null out the fields after this if they want to allow re-loading. +pub fn freeLoaded(app: *App) void { + if (app.history_series) |s| { + s.deinit(); + app.history_series = null; + } + if (app.history_load_result) |*lh| { + lh.deinit(); + app.history_load_result = null; + } +} + +/// Cycle the displayed metric: net_worth → liquid → illiquid → net_worth. +pub fn cycleMetric(app: *App) void { + app.history_metric = switch (app.history_metric) { + .net_worth => .liquid, + .liquid => .illiquid, + .illiquid => .net_worth, + }; +} + +// ── Rendering ───────────────────────────────────────────────── + +pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { + return renderHistoryLines(arena, app.theme, app.history_series, app.history_metric); +} + +/// Pure renderer — no App dependency. Builds the styled lines from a +/// timeline series and the currently-selected metric. +pub fn renderHistoryLines( + arena: std.mem.Allocator, + th: theme_mod.Theme, + series_opt: ?timeline.TimelineSeries, + metric: timeline.Metric, +) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Portfolio History", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const series = series_opt orelse { + try lines.append(arena, .{ .text = " No history snapshots yet.", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " Run: zfin snapshot (or wait for the daily cron)", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + const points = series.points; + if (points.len == 0) { + try lines.append(arena, .{ .text = " No snapshots found.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + // ── Summary block: one line per metric ─────────────────────── + try appendSummaryLine(arena, &lines, th, "Net Worth", points, .net_worth); + try appendSummaryLine(arena, &lines, th, "Liquid ", points, .liquid); + try appendSummaryLine(arena, &lines, th, "Illiquid ", points, .illiquid); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // ── Chart ──────────────────────────────────────────────────── + const metric_label = switch (metric) { + .net_worth => "Net Worth", + .liquid => "Liquid", + .illiquid => "Illiquid", + }; + const chart_header = try std.fmt.allocPrint(arena, " Chart: {s} (press 'm' to cycle)", .{metric_label}); + try lines.append(arena, .{ .text = chart_header, .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Render via the existing braille chart: convert MetricPoints into + // synthetic candles so we can call renderBrailleToStyledLines. + const candles = try arena.alloc(zfin.Candle, points.len); + for (points, 0..) |p, i| { + const value = switch (metric) { + .net_worth => p.net_worth, + .liquid => p.liquid, + .illiquid => p.illiquid, + }; + candles[i] = .{ + .date = p.as_of_date, + .open = value, + .high = value, + .low = value, + .close = value, + .adj_close = value, + .volume = 0, + }; + } + try tui.renderBrailleToStyledLines(arena, &lines, candles, th); + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // ── Recent snapshots table ─────────────────────────────────── + try lines.append(arena, .{ .text = " Recent snapshots", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Headers: Date | Illiquid | Liquid | Net Worth (components → total) + try lines.append(arena, .{ + .text = " Date Illiquid Liquid Net Worth", + .style = th.mutedStyle(), + }); + + // Show up to max_table_rows most recent, but preserve oldest-first + // ordering for deltas to accumulate intuitively. + const start = if (points.len > max_table_rows) points.len - max_table_rows else 0; + const first = points[0]; // deltas still measured from series start + + for (points[start..]) |p| { + const text = try fmtTableRow(arena, p, first); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + + return lines.toOwnedSlice(arena); +} + +fn appendSummaryLine( + arena: std.mem.Allocator, + lines: *std.ArrayList(StyledLine), + th: theme_mod.Theme, + label: []const u8, + points: []const timeline.TimelinePoint, + metric: timeline.Metric, +) !void { + const first = extractOne(points[0], metric); + const last = extractOne(points[points.len - 1], metric); + const delta = last - first; + const pct = if (first != 0) (delta / first) * 100.0 else 0.0; + + var first_buf: [24]u8 = undefined; + var last_buf: [24]u8 = undefined; + var delta_buf: [24]u8 = undefined; + + const first_s = fmt.fmtMoneyAbs(&first_buf, first); + const last_s = fmt.fmtMoneyAbs(&last_buf, last); + const delta_abs = fmt.fmtMoneyAbs(&delta_buf, @abs(delta)); + const sign: []const u8 = if (delta < 0) "-" else "+"; + + const text = try std.fmt.allocPrint(arena, " {s} first: {s} last: {s} Δ: {s}{s} ({d:.2}%)", .{ + label, first_s, last_s, sign, delta_abs, pct, + }); + + const style = if (delta < 0) th.negativeStyle() else if (delta > 0) th.positiveStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = style }); +} + +fn fmtTableRow( + arena: std.mem.Allocator, + p: timeline.TimelinePoint, + first: timeline.TimelinePoint, +) ![]const u8 { + var date_buf: [10]u8 = undefined; + var ill_buf: [24]u8 = undefined; + var liq_buf: [24]u8 = undefined; + var nw_buf: [24]u8 = undefined; + + const date_s = p.as_of_date.format(&date_buf); + const ill_s = fmt.fmtMoneyAbs(&ill_buf, p.illiquid); + const liq_s = fmt.fmtMoneyAbs(&liq_buf, p.liquid); + const nw_s = fmt.fmtMoneyAbs(&nw_buf, p.net_worth); + _ = first; // reserved — could render Δ columns here later + + return std.fmt.allocPrint(arena, " {s} {s:>16} {s:>16} {s:>16}", .{ + date_s, ill_s, liq_s, nw_s, + }); +} + +fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 { + return switch (metric) { + .net_worth => p.net_worth, + .liquid => p.liquid, + .illiquid => p.illiquid, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; +const Date = zfin.Date; +const snapshot = @import("../models/snapshot.zig"); + +test "renderHistoryLines: no series shows no-data message" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const th = theme_mod.default_theme; + + const lines = try renderHistoryLines(a, th, null, .net_worth); + var saw_no_data = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "No history snapshots") != null) saw_no_data = true; + } + try testing.expect(saw_no_data); +} + +test "renderHistoryLines: renders summary + chart + table" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const th = theme_mod.default_theme; + + // Build a tiny timeline by hand (bypasses buildSeries + its snapshot + // input). Two points: day1 and day2. + const pts = try a.alloc(timeline.TimelinePoint, 2); + pts[0] = .{ + .as_of_date = Date.fromYmd(2026, 4, 20), + .net_worth = 1000, + .liquid = 700, + .illiquid = 300, + .accounts = &.{}, + .tax_types = &.{}, + }; + pts[1] = .{ + .as_of_date = Date.fromYmd(2026, 4, 21), + .net_worth = 1100, + .liquid = 800, + .illiquid = 300, + .accounts = &.{}, + .tax_types = &.{}, + }; + + const series: timeline.TimelineSeries = .{ + .points = pts, + .allocator = a, // arena, so deinit is safe no-op + }; + + const lines = try renderHistoryLines(a, th, series, .net_worth); + // Expect: header + summary rows + chart header + chart + table header + try testing.expect(lines.len > 10); + + var saw_header = false; + var saw_net_worth = false; + var saw_table = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "Portfolio History") != null) saw_header = true; + if (std.mem.indexOf(u8, l.text, "Net Worth") != null) saw_net_worth = true; + if (std.mem.indexOf(u8, l.text, "Recent snapshots") != null) saw_table = true; + } + try testing.expect(saw_header); + try testing.expect(saw_net_worth); + try testing.expect(saw_table); +} + +test "renderHistoryLines: metric switching changes chart label" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const th = theme_mod.default_theme; + + const pts = try a.alloc(timeline.TimelinePoint, 1); + pts[0] = .{ + .as_of_date = Date.fromYmd(2026, 4, 20), + .net_worth = 100, + .liquid = 60, + .illiquid = 40, + .accounts = &.{}, + .tax_types = &.{}, + }; + const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a }; + + const lines_liquid = try renderHistoryLines(a, th, series, .liquid); + var saw_liquid_chart = false; + for (lines_liquid) |l| { + if (std.mem.indexOf(u8, l.text, "Chart: Liquid") != null) saw_liquid_chart = true; + } + try testing.expect(saw_liquid_chart); + + const lines_ill = try renderHistoryLines(a, th, series, .illiquid); + var saw_ill_chart = false; + for (lines_ill) |l| { + if (std.mem.indexOf(u8, l.text, "Chart: Illiquid") != null) saw_ill_chart = true; + } + try testing.expect(saw_ill_chart); +} + +test "cycleMetric: walks net_worth → liquid → illiquid → net_worth" { + // Test the pure logic without needing an App — replicate inline. + var m: timeline.Metric = .net_worth; + m = switch (m) { + .net_worth => .liquid, + .liquid => .illiquid, + .illiquid => .net_worth, + }; + try testing.expectEqual(timeline.Metric.liquid, m); + m = switch (m) { + .net_worth => .liquid, + .liquid => .illiquid, + .illiquid => .net_worth, + }; + try testing.expectEqual(timeline.Metric.illiquid, m); + m = switch (m) { + .net_worth => .liquid, + .liquid => .illiquid, + .illiquid => .net_worth, + }; + try testing.expectEqual(timeline.Metric.net_worth, m); +} + +// Keep refAllDeclsRecursive happy +test { + _ = snapshot; +} diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index d6916a6..814d02d 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -13,6 +13,7 @@ pub const Action = enum { tab_4, tab_5, tab_6, + tab_7, scroll_down, scroll_up, scroll_top, @@ -39,6 +40,7 @@ pub const Action = enum { options_filter_9, chart_timeframe_next, chart_timeframe_prev, + history_metric_next, sort_col_next, sort_col_prev, sort_reverse, @@ -94,6 +96,7 @@ const default_bindings = [_]Binding{ .{ .action = .tab_4, .key = .{ .codepoint = '4' } }, .{ .action = .tab_5, .key = .{ .codepoint = '5' } }, .{ .action = .tab_6, .key = .{ .codepoint = '6' } }, + .{ .action = .tab_7, .key = .{ .codepoint = '7' } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, @@ -122,6 +125,7 @@ const default_bindings = [_]Binding{ .{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, + .{ .action = .history_metric_next, .key = .{ .codepoint = 'm' } }, .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } }, .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },