initial tui history implementation

This commit is contained in:
Emil Lerch 2026-04-23 00:32:18 -07:00
parent abd5d08af7
commit dac310e38e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 436 additions and 14 deletions

View file

@ -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.

378
src/tui/history_tab.zig Normal file
View file

@ -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;
}

View file

@ -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' } },