//! 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; }