initial tui history implementation
This commit is contained in:
parent
abd5d08af7
commit
dac310e38e
3 changed files with 436 additions and 14 deletions
68
src/tui.zig
68
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.
|
||||
|
|
|
|||
378
src/tui/history_tab.zig
Normal file
378
src/tui/history_tab.zig
Normal 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;
|
||||
}
|
||||
|
|
@ -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' } },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue