510 lines
19 KiB
Zig
510 lines
19 KiB
Zig
//! TUI history tab — portfolio value timeline over time.
|
|
//!
|
|
//! Layout (top-to-bottom):
|
|
//! 1. Rolling-windows block for the focused metric
|
|
//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time)
|
|
//! 2. Braille timeline chart for the focused metric
|
|
//! 3. "Recent snapshots" table: Liquid | Illiquid | Net Worth with
|
|
//! per-row Δ vs. previous row. Newest-first. Row colored by the
|
|
//! focused-metric delta.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! Keybinds:
|
|
//! - `m` cycles chart metric (`history_metric_next`)
|
|
//! - `t` cycles resolution (`history_resolution_next`)
|
|
//!
|
|
//! Default metric is `.liquid` — matches the CLI history default and
|
|
//! is the headline view for day-to-day watching (illiquid barely
|
|
//! changes, net worth is dominated by liquid anyway).
|
|
|
|
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const theme = @import("theme.zig");
|
|
const tui = @import("../tui.zig");
|
|
const history_io = @import("../history.zig");
|
|
const timeline = @import("../analytics/timeline.zig");
|
|
const view = @import("../views/history.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 windows block, just not to the table.
|
|
const max_table_rows: usize = 30;
|
|
|
|
// ── Data loading ──────────────────────────────────────────────
|
|
|
|
pub fn loadData(app: *App) void {
|
|
app.history_loaded = true;
|
|
freeLoaded(app);
|
|
|
|
const portfolio_path = app.portfolio_path orelse {
|
|
app.setStatus("History tab requires a loaded portfolio");
|
|
return;
|
|
};
|
|
|
|
app.history_timeline = history_io.loadTimeline(app.allocator, portfolio_path) catch {
|
|
app.setStatus("Failed to read history/ directory");
|
|
return;
|
|
};
|
|
|
|
if (app.history_timeline.?.loaded.snapshots.len == 0) {
|
|
freeLoaded(app);
|
|
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
|
|
}
|
|
}
|
|
|
|
/// Release the loaded timeline (if any).
|
|
pub fn freeLoaded(app: *App) void {
|
|
if (app.history_timeline) |*tl| {
|
|
tl.deinit();
|
|
app.history_timeline = null;
|
|
}
|
|
}
|
|
|
|
/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid.
|
|
///
|
|
/// Starts at liquid to match the default and the "most-useful-first"
|
|
/// reading order: markets first, illiquid revaluations second, total
|
|
/// last.
|
|
pub fn cycleMetric(app: *App) void {
|
|
app.history_metric = switch (app.history_metric) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
}
|
|
|
|
/// Cycle resolution: auto → daily → weekly → monthly → auto.
|
|
///
|
|
/// Null = auto (defers to `timeline.selectResolution`). The cycle runs
|
|
/// through the explicit choices so the user can force a given resolution
|
|
/// when the auto pick doesn't match intent.
|
|
pub fn cycleResolution(app: *App) void {
|
|
app.history_resolution = switch (app.history_resolution orelse {
|
|
app.history_resolution = .daily;
|
|
return;
|
|
}) {
|
|
.daily => .weekly,
|
|
.weekly => .monthly,
|
|
.monthly => null, // back to auto
|
|
};
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────
|
|
|
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
const series: ?timeline.TimelineSeries = if (app.history_timeline) |tl| tl.series else null;
|
|
return renderHistoryLines(arena, app.theme, series, app.history_metric, app.history_resolution);
|
|
}
|
|
|
|
/// Pure renderer — no App dependency. Builds the styled lines from a
|
|
/// timeline series, a focused metric, and an optional resolution override.
|
|
pub fn renderHistoryLines(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
series_opt: ?timeline.TimelineSeries,
|
|
focus_metric: timeline.Metric,
|
|
resolution_override: ?timeline.Resolution,
|
|
) ![]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);
|
|
}
|
|
|
|
const metric_label = focus_metric.label();
|
|
|
|
// ── Windows block (focused metric only) ──────────────────────
|
|
try appendWindowsBlock(arena, &lines, th, points, focus_metric, metric_label);
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Chart ────────────────────────────────────────────────────
|
|
const chart_header = try std.fmt.allocPrint(
|
|
arena,
|
|
" Chart: {s} (press 'm' to cycle metric, 't' to cycle resolution)",
|
|
.{metric_label},
|
|
);
|
|
try lines.append(arena, .{ .text = chart_header, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Chart: synthesize candles from the focused metric's value.
|
|
const candles = try arena.alloc(zfin.Candle, points.len);
|
|
for (points, 0..) |p, i| {
|
|
const value = extractOne(p, focus_metric);
|
|
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 ───────────────────────────────────
|
|
const resolution = resolution_override orelse timeline.selectResolution(points);
|
|
const aggregated = try timeline.aggregatePoints(arena, points, resolution);
|
|
const deltas = try timeline.computeRowDeltas(arena, aggregated);
|
|
|
|
var rlabel_buf: [32]u8 = undefined;
|
|
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
|
|
const table_header = try std.fmt.allocPrint(
|
|
arena,
|
|
" Recent snapshots {s}",
|
|
.{rlabel},
|
|
);
|
|
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Header widths mirror the CLI exactly. Leading " " indent + 10-char
|
|
// date + 2 gap + three 28-char composite cells separated by 2 gaps.
|
|
const header_line = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:>10} {s:>28} {s:>28} {s:>28}",
|
|
.{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
|
|
);
|
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
|
|
|
// Render up to max_table_rows newest-first.
|
|
const start = if (deltas.len > max_table_rows) deltas.len - max_table_rows else 0;
|
|
const window = deltas[start..];
|
|
var i: usize = window.len;
|
|
while (i > 0) {
|
|
i -= 1;
|
|
const row = window[i];
|
|
const text = try fmtTableRow(arena, row);
|
|
const style = rowStyle(th, row, focus_metric);
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// Render the rolling-windows block into `lines`. Output matches the
|
|
/// CLI byte-for-byte (modulo ANSI) — same widths, same labels, same
|
|
/// dashed divider — because both call `view.buildWindowRowCells`.
|
|
fn appendWindowsBlock(
|
|
arena: std.mem.Allocator,
|
|
lines: *std.ArrayList(StyledLine),
|
|
th: theme.Theme,
|
|
points: []const timeline.TimelinePoint,
|
|
metric: timeline.Metric,
|
|
metric_label: []const u8,
|
|
) !void {
|
|
_ = metric_label; // outer "Portfolio History" + chart header already name the metric
|
|
|
|
const today = points[points.len - 1].as_of_date;
|
|
const ws = try timeline.computeWindowSet(arena, points, metric, today);
|
|
// Arena-backed: no deinit needed.
|
|
|
|
// Block title — just "Change". Metric is redundant with the chart
|
|
// header below ("Chart: Liquid") and the outer "Portfolio History".
|
|
try lines.append(arena, .{ .text = " Change", .style = th.headerStyle() });
|
|
|
|
// Column header + dashed divider. Widths pinned to view constants
|
|
// (12 / 18 / 10).
|
|
const header_line = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:<12} {s:>18} {s:>10}",
|
|
.{ "", "Δ", "%" },
|
|
);
|
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
|
try lines.append(arena, .{
|
|
.text = " ------------ ------------------ ----------",
|
|
.style = th.mutedStyle(),
|
|
});
|
|
|
|
for (ws.rows) |row| {
|
|
var dbuf: [32]u8 = undefined;
|
|
var pbuf: [16]u8 = undefined;
|
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
|
|
|
|
const text = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:<12} {s:>18} {s:>10}",
|
|
.{ cells.label, cells.delta_str, cells.pct_str },
|
|
);
|
|
const style: vaxis.Cell.Style = switch (cells.style) {
|
|
.positive => th.positiveStyle(),
|
|
.negative => th.negativeStyle(),
|
|
.muted => th.mutedStyle(),
|
|
// `normal` is unreachable in the windows block (build
|
|
// never emits it); fall back to content style to keep
|
|
// the switch exhaustive.
|
|
.normal => th.contentStyle(),
|
|
};
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
}
|
|
|
|
/// Build a recent-snapshots table row. Cells align with the header
|
|
/// because both use `view.fmtValueDeltaCell` with `view.table_cell_width`.
|
|
fn fmtTableRow(arena: std.mem.Allocator, row: timeline.RowDelta) ![]const u8 {
|
|
var date_buf: [10]u8 = undefined;
|
|
var liq_cell_buf: [64]u8 = undefined;
|
|
var ill_cell_buf: [64]u8 = undefined;
|
|
var nw_cell_buf: [64]u8 = undefined;
|
|
|
|
const date_s = row.date.format(&date_buf);
|
|
const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width);
|
|
const ill_cell = view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
|
|
const nw_cell = view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);
|
|
|
|
return std.fmt.allocPrint(
|
|
arena,
|
|
" {s:>10} {s} {s} {s}",
|
|
.{ date_s, liq_cell, ill_cell, nw_cell },
|
|
);
|
|
}
|
|
|
|
fn rowStyle(th: theme.Theme, row: timeline.RowDelta, metric: timeline.Metric) vaxis.Cell.Style {
|
|
const d_opt: ?f64 = switch (metric) {
|
|
.liquid => row.d_liquid,
|
|
.illiquid => row.d_illiquid,
|
|
.net_worth => row.d_net_worth,
|
|
};
|
|
if (d_opt) |d| {
|
|
if (d < 0) return th.negativeStyle();
|
|
if (d > 0) return th.positiveStyle();
|
|
}
|
|
return th.mutedStyle();
|
|
}
|
|
|
|
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.default_theme;
|
|
|
|
const lines = try renderHistoryLines(a, th, null, .liquid, null);
|
|
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 windows + chart + table in correct order" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
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 };
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, .daily);
|
|
|
|
// Find the indices of the three major section headers.
|
|
var windows_idx: ?usize = null;
|
|
var chart_idx: ?usize = null;
|
|
var table_idx: ?usize = null;
|
|
for (lines, 0..) |l, i| {
|
|
if (std.mem.eql(u8, std.mem.trim(u8, l.text, " "), "Change")) windows_idx = i;
|
|
if (std.mem.indexOf(u8, l.text, "Chart: Liquid") != null) chart_idx = i;
|
|
if (std.mem.indexOf(u8, l.text, "Recent snapshots") != null) table_idx = i;
|
|
}
|
|
try testing.expect(windows_idx != null);
|
|
try testing.expect(chart_idx != null);
|
|
try testing.expect(table_idx != null);
|
|
// Order: windows → chart → table
|
|
try testing.expect(windows_idx.? < chart_idx.?);
|
|
try testing.expect(chart_idx.? < table_idx.?);
|
|
}
|
|
|
|
test "renderHistoryLines: windows block includes 1 day + All-time" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
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 };
|
|
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, null);
|
|
|
|
var saw_1d = false;
|
|
var saw_all_time = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "1 day") != null) saw_1d = true;
|
|
if (std.mem.indexOf(u8, l.text, "All-time") != null) saw_all_time = true;
|
|
}
|
|
try testing.expect(saw_1d);
|
|
try testing.expect(saw_all_time);
|
|
}
|
|
|
|
test "renderHistoryLines: table rows emitted newest-first and column order is Liquid → Illiquid → NW" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 3);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2026, 4, 19),
|
|
.net_worth = 900,
|
|
.liquid = 600,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[1] = .{
|
|
.as_of_date = Date.fromYmd(2026, 4, 20),
|
|
.net_worth = 1000,
|
|
.liquid = 700,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[2] = .{
|
|
.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 };
|
|
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, .daily);
|
|
|
|
// Join all lines to scan row ordering.
|
|
var joined: std.ArrayList(u8) = .empty;
|
|
for (lines) |l| {
|
|
try joined.appendSlice(a, l.text);
|
|
try joined.append(a, '\n');
|
|
}
|
|
const text = joined.items;
|
|
|
|
// Header column order: Liquid before Illiquid before Net Worth
|
|
const h_liq = std.mem.indexOf(u8, text, "Liquid") orelse return error.TestExpectedMatch;
|
|
const h_ill = std.mem.indexOf(u8, text, "Illiquid") orelse return error.TestExpectedMatch;
|
|
const h_nw = std.mem.indexOf(u8, text, "Net Worth") orelse return error.TestExpectedMatch;
|
|
try testing.expect(h_liq < h_ill);
|
|
try testing.expect(h_ill < h_nw);
|
|
|
|
// Newest-first: 2026-04-21 appears before 2026-04-19 in the text
|
|
const d_new = std.mem.lastIndexOf(u8, text, "2026-04-21") orelse return error.TestExpectedMatch;
|
|
const d_old = std.mem.lastIndexOf(u8, text, "2026-04-19") orelse return error.TestExpectedMatch;
|
|
try testing.expect(d_new < d_old);
|
|
}
|
|
|
|
test "renderHistoryLines: metric cycling changes chart label and windows header" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.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_ill = try renderHistoryLines(a, th, series, .illiquid, null);
|
|
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: liquid → illiquid → net_worth → liquid" {
|
|
var m: timeline.Metric = .liquid;
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.illiquid, m);
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.net_worth, m);
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.liquid, m);
|
|
}
|
|
|
|
// Keep refAllDeclsRecursive happy
|
|
test {
|
|
_ = snapshot;
|
|
}
|