//! TUI history tab — portfolio value timeline + compare-mode overlay. //! //! Timeline 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. //! //! Compare mode overrides the entire output with a `CompareView` //! render when two rows have been selected (via `s` / space) and //! confirmed via `c`. Esc or another `c` returns to timeline. //! //! Selection UX: //! - `s` / space — toggle selection of the row under the cursor. //! Up to two rows can be selected; a third attempt is rejected //! with a status hint. //! - `c` — run compare if exactly two rows are selected; otherwise //! status hint. //! - Esc — exit compare view if active, else clear pending selections. //! //! The "today (live)" pseudo-row is conditional: it appears as the //! newest row when `app.portfolio_summary` and `app.prefetched_prices` //! are populated. When present, `history_cursor = 0` points at it. //! //! Consumes `src/analytics/timeline.zig` (pure compute) and //! `src/history.zig` (snapshot IO). Compare composition is delegated //! to `src/compare.zig`; compare rendering to `src/views/compare.zig`. //! //! Keybinds: //! - `m` cycles chart metric (`history_metric_next`) //! - `t` cycles resolution (`history_resolution_next`) //! - `s` / space / `c` / Esc — compare (intercepted in `tui.zig` //! before matchAction, see `handleCompareKey` below) 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 compare_core = @import("../compare.zig"); const compare_view = @import("../views/compare.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── 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; } clearCompareView(app); } /// Clear the compare-view state (selections are preserved). fn clearCompareView(app: *App) void { if (app.history_compare_view) |*cv| { cv.deinit(app.allocator); app.history_compare_view = null; } if (app.history_compare_resources) |*res| { res.deinit(app.allocator); app.history_compare_resources = null; } } /// Cycle the displayed metric: liquid → illiquid → net_worth → liquid. 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. 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 }; } // ── Compare selection model ────────────────────────────────── /// Returns the number of currently selected rows (0, 1, or 2). fn selectionCount(app: *const App) usize { var n: usize = 0; for (app.history_selections) |s| { if (s != null) n += 1; } return n; } /// Returns true if row `idx` is currently selected. fn isSelected(app: *const App, idx: usize) bool { for (app.history_selections) |s| { if (s) |v| if (v == idx) return true; } return false; } /// Toggle selection of row `idx`. With two already selected and this /// row isn't one of them, sets a status hint and leaves state alone. fn toggleSelection(app: *App, idx: usize) void { // Already selected? Remove. for (&app.history_selections) |*slot| { if (slot.*) |v| { if (v == idx) { slot.* = null; setSelectionStatus(app); return; } } } // Find an empty slot. for (&app.history_selections) |*slot| { if (slot.* == null) { slot.* = idx; setSelectionStatus(app); return; } } // Both full, and this row isn't one of them. app.setStatus("Two rows already selected — 's' on a selected row to deselect, or 'c' to compare"); } /// Clear all selections. fn clearSelections(app: *App) void { app.history_selections = .{ null, null }; } fn setSelectionStatus(app: *App) void { const n = selectionCount(app); switch (n) { 0 => app.setStatus(""), 1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"), 2 => app.setStatus("Selected 2 rows — press 'c' to compare"), else => unreachable, } } /// Public entry for the `compare_select` action dispatched via /// matchAction. Internally delegates to the same toggle function the /// intercept uses. pub fn toggleSelectionAt(app: *App, idx: usize) void { toggleSelection(app, idx); } /// Public entry for the `compare_commit` action. pub fn commitCompareExternal(app: *App) void { commitCompare(app); } /// Public entry for the `compare_cancel` action or internal Esc path: /// clear selections and any active compare view. pub fn clearCompareState(app: *App) void { clearCompareView(app); clearSelections(app); app.setStatus(""); } // ── Compare commit ─────────────────────────────────────────── /// Attempt to run compare. Called when the user presses `c`. /// No-ops (with status hint) if the selection set isn't exactly 2. fn commitCompare(app: *App) void { const sel_count = selectionCount(app); if (sel_count < 2) { if (sel_count == 0) { app.setStatus("Select two rows with 's' (or space), then press 'c' to compare"); } else { app.setStatus("Select one more row with 's' (or space), then press 'c' to compare"); } return; } // At this point both slots are filled. const sel_a = app.history_selections[0].?; const sel_b = app.history_selections[1].?; if (sel_a == sel_b) { // Shouldn't happen via toggle logic, but guard anyway. app.setStatus("Selected rows are the same — clear one and reselect"); return; } buildCompareFromSelections(app, sel_a, sel_b) catch |err| { var msg_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed"; app.setStatus(msg); clearCompareView(app); }; } /// Build + stash the compare view from two selected row indices. fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { // Resolve each row-index into its date and source (live vs // snapshot). This requires re-computing the table row list the // way the renderer does — shared helper keeps the two paths in // sync. var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const rows = try collectTableRows(arena, app); if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) { app.setStatus("Stale selection — please re-select"); clearSelections(app); return; } const row_a = rows[sel_a]; const row_b = rows[sel_b]; // Order: older → newer. const older = if (row_a.date.days < row_b.date.days) row_a else row_b; const newer = if (row_a.date.days < row_b.date.days) row_b else row_a; // Build up the resources + maps for each side. var resources: tui.HistoryCompareResources = .{}; errdefer resources.deinit(app.allocator); const portfolio_path = app.portfolio_path orelse { app.setStatus("No portfolio loaded — can't build compare"); return error.PortfolioLoadFailed; }; const hist_dir = try history_io.deriveHistoryDir(app.allocator, portfolio_path); defer app.allocator.free(hist_dir); // SAFETY: assigned in both branches of the `if (older.is_live)` // below before first read (via `then_map_ptr.*` when passed to // buildCompareView). var then_map_ptr: *compare_view.HoldingMap = undefined; var then_liquid: f64 = 0; // "Then" side. if (older.is_live) { var map: compare_view.HoldingMap = .init(app.allocator); errdefer map.deinit(); try aggregateFromSummary(app.portfolio_summary.?, &map); resources.then_live_map = map; then_map_ptr = &resources.then_live_map.?; then_liquid = liveLiquid(app); } else { const side = try compare_core.loadSnapshotSide(app.allocator, hist_dir, older.date); resources.then_snap = side; then_map_ptr = &resources.then_snap.?.map; then_liquid = side.liquid; } // SAFETY: assigned in both branches of the `if (newer.is_live)` // below before first read. var now_map_ptr: *compare_view.HoldingMap = undefined; var now_liquid: f64 = 0; if (newer.is_live) { var map: compare_view.HoldingMap = .init(app.allocator); errdefer map.deinit(); try aggregateFromSummary(app.portfolio_summary.?, &map); resources.now_live_map = map; now_map_ptr = &resources.now_live_map.?; now_liquid = liveLiquid(app); } else { const side = try compare_core.loadSnapshotSide(app.allocator, hist_dir, newer.date); resources.now_snap = side; now_map_ptr = &resources.now_snap.?.map; now_liquid = side.liquid; } // "now is live" only when the NEWER endpoint is the live row. const now_is_live = newer.is_live; const cv = try compare_view.buildCompareView( app.allocator, older.date, newer.date, now_is_live, then_liquid, now_liquid, then_map_ptr, now_map_ptr, ); // No error paths between here and install, so no errdefer needed. // If buildCompareView returned Ok, we own cv's backing memory // (allocated inside buildCompareView) and pass ownership to the App. // Commit: install both onto the App. From this point onwards the // App owns them and clearCompareView handles teardown. clearCompareView(app); app.history_compare_view = cv; app.history_compare_resources = resources; app.setStatus("Comparing — Esc or 'c' to return to timeline"); } fn liveLiquid(app: *const App) f64 { if (app.portfolio_summary) |s| return s.total_value; return 0; } /// Aggregate the live portfolio's per-symbol holdings from the /// already-computed `PortfolioSummary.allocations`. /// /// We derive the post-`price_ratio` per-share price from /// `market_value / shares` rather than reading raw prices + applying /// ratio ourselves. This matches the snapshot aggregation (which /// stores post-ratio prices in its lot rows) and keeps the two /// endpoints apples-to-apples. Edge case: covered-call adjustments /// (`adjustForCoveredCalls`) skew the derived per-share price /// slightly for symbols with ITM sold calls; matches the CLI behavior /// and is acceptable. fn aggregateFromSummary( summary: zfin.valuation.PortfolioSummary, out: *compare_view.HoldingMap, ) !void { for (summary.allocations) |a| { if (a.shares == 0) continue; const price = a.market_value / a.shares; try out.put(a.symbol, .{ .shares = a.shares, .price = price }); } } // ── Compare key handler (intercepted from tui.zig) ─────────── /// Intercepted before `matchAction` runs when the active tab is /// history. Returns true if the key was consumed. pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) bool { // Escape: exit compare view, or clear selections. if (key.codepoint == vaxis.Key.escape) { if (app.history_compare_view != null) { clearCompareView(app); clearSelections(app); app.setStatus(""); ctx.consumeAndRedraw(); return true; } if (selectionCount(app) > 0) { clearSelections(app); app.setStatus(""); ctx.consumeAndRedraw(); return true; } return false; } // 's' or space: toggle selection on the cursor row. if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) { // Disabled while the compare view is up — Esc to return first. if (app.history_compare_view != null) return false; if (app.history_table_row_count == 0) return false; toggleSelection(app, app.history_cursor); ctx.consumeAndRedraw(); return true; } // 'c': commit compare, or exit compare view if already active. if (key.matches('c', .{})) { if (app.history_compare_view != null) { clearCompareView(app); clearSelections(app); app.setStatus(""); ctx.consumeAndRedraw(); return true; } commitCompare(app); ctx.consumeAndRedraw(); return true; } return false; } // ── Table row model ────────────────────────────────────────── /// One row in the rendered recent-snapshots table. /// /// `is_live` is true for the synthesized "today (live)" pseudo-row /// at index 0 when the TUI has portfolio-summary state loaded. All /// other rows correspond to real snapshots, one per `RowDelta`. pub const TableRow = struct { date: zfin.Date, is_live: bool, liquid: f64, illiquid: f64, net_worth: f64, d_liquid: ?f64, d_illiquid: ?f64, d_net_worth: ?f64, }; /// Build the list of table rows in display order (newest-first). /// /// Row 0 is the live pseudo-row if available, followed by all snapshot /// rows from newest to oldest. The `RowDelta` slice from /// `computeRowDeltas` is oldest-first; we reverse it and optionally /// prepend the live row. pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow { const timeline_opt = app.history_timeline; if (timeline_opt == null) return &.{}; const series = timeline_opt.?.series; if (series.points.len == 0) return &.{}; const resolution = app.history_resolution orelse timeline.selectResolution(series.points); const aggregated = try timeline.aggregatePoints(arena, series.points, resolution); const deltas = try timeline.computeRowDeltas(arena, aggregated); // Decide whether to prepend a live pseudo-row. Requires both a // portfolio (for illiquid) and a summary (for liquid total). const live_opt = buildLiveRow(app, deltas); var list: std.ArrayList(TableRow) = .empty; try list.ensureTotalCapacity(arena, deltas.len + (if (live_opt == null) @as(usize, 0) else @as(usize, 1))); if (live_opt) |live| try list.append(arena, live); // deltas is oldest-first; emit newest-first. var i: usize = deltas.len; while (i > 0) { i -= 1; const d = deltas[i]; try list.append(arena, .{ .date = d.date, .is_live = false, .liquid = d.liquid, .illiquid = d.illiquid, .net_worth = d.net_worth, .d_liquid = d.d_liquid, .d_illiquid = d.d_illiquid, .d_net_worth = d.d_net_worth, }); } return list.toOwnedSlice(arena); } /// Build the synthetic "today (live)" row from App state, or return /// null if the required state isn't populated. /// /// Deltas are computed against the newest snapshot in `deltas` (which /// is index `deltas.len - 1` — deltas is oldest-first). fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow { if (app.portfolio == null) return null; const summary = app.portfolio_summary orelse return null; const liquid = summary.total_value; const illiquid = app.portfolio.?.totalIlliquid(); const net_worth = liquid + illiquid; // Deltas vs. the most recent snapshot. var d_liquid: ?f64 = null; var d_illiquid: ?f64 = null; var d_net_worth: ?f64 = null; if (deltas.len > 0) { const newest = deltas[deltas.len - 1]; d_liquid = liquid - newest.liquid; d_illiquid = illiquid - newest.illiquid; d_net_worth = net_worth - newest.net_worth; } return .{ .date = fmt.todayDate(), .is_live = true, .liquid = liquid, .illiquid = illiquid, .net_worth = net_worth, .d_liquid = d_liquid, .d_illiquid = d_illiquid, .d_net_worth = d_net_worth, }; } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { // Compare mode short-circuits the timeline render. if (app.history_compare_view) |cv| { // Compare view doesn't populate table metadata; reset so the // cursor state remains sensible if the user exits back. return renderCompareLines(arena, app.theme, cv); } const rows = try collectTableRows(arena, app); const result = try renderHistoryLinesFull( arena, app.theme, if (app.history_timeline) |tl| tl.series else null, app.history_metric, app.history_resolution, rows, app.history_cursor, app.history_selections, ); // Stash table metadata on the App for the event handler's cursor- // visibility logic. app.history_table_first_line = result.table_first_line; app.history_table_row_count = result.table_row_count; // Clamp cursor in case the row count shrank since the last press // (shouldn't happen often but guards against subtle off-by-ones). if (result.table_row_count == 0) { app.history_cursor = 0; } else if (app.history_cursor >= result.table_row_count) { app.history_cursor = result.table_row_count - 1; } return result.lines; } /// Thin wrapper so existing tests calling `renderHistoryLines` /// continue to pass: no cursor, no selections, no live row, no /// metadata. Internal callers should prefer `renderHistoryLinesFull`. pub fn renderHistoryLines( arena: std.mem.Allocator, th: theme.Theme, series_opt: ?timeline.TimelineSeries, focus_metric: timeline.Metric, resolution_override: ?timeline.Resolution, ) ![]const StyledLine { const rows = try rowsFromSeries(arena, series_opt, resolution_override); const result = try renderHistoryLinesFull( arena, th, series_opt, focus_metric, resolution_override, rows, 0, .{ null, null }, ); return result.lines; } fn rowsFromSeries( arena: std.mem.Allocator, series_opt: ?timeline.TimelineSeries, resolution_override: ?timeline.Resolution, ) ![]TableRow { const series = series_opt orelse return &.{}; if (series.points.len == 0) return &.{}; const resolution = resolution_override orelse timeline.selectResolution(series.points); const aggregated = try timeline.aggregatePoints(arena, series.points, resolution); const deltas = try timeline.computeRowDeltas(arena, aggregated); var list: std.ArrayList(TableRow) = .empty; try list.ensureTotalCapacity(arena, deltas.len); var i: usize = deltas.len; while (i > 0) { i -= 1; const d = deltas[i]; try list.append(arena, .{ .date = d.date, .is_live = false, .liquid = d.liquid, .illiquid = d.illiquid, .net_worth = d.net_worth, .d_liquid = d.d_liquid, .d_illiquid = d.d_illiquid, .d_net_worth = d.d_net_worth, }); } return list.toOwnedSlice(arena); } /// Full render result: the styled lines plus metadata the event /// handler needs for cursor-visibility logic. pub const HistoryRender = struct { lines: []const StyledLine, /// Line index of the first data row in the recent-snapshots table. /// Zero when the table isn't rendered (no data). table_first_line: usize, /// Number of data rows in the table (includes live pseudo-row). table_row_count: usize, }; /// Full renderer: takes cursor + selections + prebuilt rows (already /// in display order, newest-first) and returns styled lines plus /// cursor metadata. Pure — no App dependency — so tests can drive it. pub fn renderHistoryLinesFull( arena: std.mem.Allocator, th: theme.Theme, series_opt: ?timeline.TimelineSeries, focus_metric: timeline.Metric, resolution_override: ?timeline.Resolution, rows: []const TableRow, cursor: usize, selections: [2]?usize, ) !HistoryRender { 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 = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 }; }; const points = series.points; if (points.len == 0) { try lines.append(arena, .{ .text = " No snapshots found.", .style = th.mutedStyle() }); return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 }; } 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); 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} (j/k: move, s/space: select, c: compare)", .{rlabel}, ); try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Column header: extra 2-char left margin for the selection marker. 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() }); const table_first_line = lines.items.len; for (rows, 0..) |row, idx| { const is_cursor = idx == cursor; const selected = isIndexSelected(selections, idx); const text = try fmtTableRow(arena, row, selected); const style = if (is_cursor) th.selectStyle() else if (selected) th.headerStyle() else rowStyle(th, row, focus_metric); try lines.append(arena, .{ .text = text, .style = style }); } return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = table_first_line, .table_row_count = rows.len, }; } fn isIndexSelected(selections: [2]?usize, idx: usize) bool { for (selections) |s| { if (s) |v| if (v == idx) return true; } return false; } /// 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; const today = points[points.len - 1].as_of_date; const ws = try timeline.computeWindowSet(arena, points, metric, today); try lines.append(arena, .{ .text = " Change", .style = th.headerStyle() }); 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 = th.styleFor(cells.style); try lines.append(arena, .{ .text = text, .style = style }); } } /// Build a recent-snapshots table row. `selected` causes a `*` marker /// to appear in the two-column left margin instead of spaces. fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]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; // Live row: replace the date column with "today (live)" right-aligned to 10. const date_s: []const u8 = if (row.is_live) "today" else 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); const marker: []const u8 = if (selected) "* " else " "; return std.fmt.allocPrint( arena, " {s}{s:>10} {s} {s} {s}", .{ marker, date_s, liq_cell, ill_cell, nw_cell }, ); } fn rowStyle(th: theme.Theme, row: TableRow, 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, }; } // ── Compare-mode rendering ──────────────────────────────────── // // Thin adapter: pulls pre-formatted cells from `views/compare.zig` // and drops them into vaxis-styled lines. Layout widths, number // formatting, and label pluralization all come from the view layer — // this function owns only the TUI-specific style mapping (via // `theme.styleFor`) and the " " leading indent that matches the // rest of the history tab. The CLI renderer in `commands/compare.zig` // uses the same shared cells + format constants, so the two outputs // stay in lockstep. /// Render a `CompareView` as a sequence of styled lines for the TUI. /// Allocates all line text into `arena`; strings borrow from it. pub fn renderCompareLines( arena: std.mem.Allocator, th: theme.Theme, cv: compare_view.CompareView, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; // Spacer matching the rest of the tab for layout consistency try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── Header ── var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; const then_str = cv.then_date.format(&then_buf); const now_str = compare_view.nowLabel(cv, &now_buf); const header = try std.fmt.allocPrint( arena, " Portfolio comparison: {s} → {s} ({d} day{s})", .{ then_str, now_str, cv.days_between, compare_view.dayPlural(cv.days_between) }, ); try lines.append(arena, .{ .text = header, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── Totals line ── { var t_then: [24]u8 = undefined; var t_now: [24]u8 = undefined; var t_delta: [32]u8 = undefined; var t_pct: [16]u8 = undefined; const t = compare_view.buildTotalsCells(cv.liquid, &t_then, &t_now, &t_delta, &t_pct); const totals_text = try std.fmt.allocPrint( arena, " Liquid: {s}{s}{s} {s} {s}", .{ t.then, compare_view.arrow, t.now, t.delta, t.pct }, ); try lines.append(arena, .{ .text = totals_text, .style = th.styleFor(t.style) }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── Per-symbol table ── if (cv.held_count == 0) { try lines.append(arena, .{ .text = " No symbols held throughout this period.", .style = th.mutedStyle(), }); } else { const subtitle = try std.fmt.allocPrint( arena, " Per-symbol price change ({d} held throughout)", .{cv.held_count}, ); try lines.append(arena, .{ .text = subtitle, .style = th.mutedStyle() }); for (cv.symbols) |s| { var p_then: [24]u8 = undefined; var p_now: [24]u8 = undefined; var p_pct: [16]u8 = undefined; var p_dollar: [32]u8 = undefined; const c = compare_view.buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar); // Single-color per row using the shared row template. const row_text = try std.fmt.allocPrint( arena, " " ++ compare_view.symbol_row_fmt, .{ c.symbol, c.price_then, compare_view.arrow, c.price_now, c.pct, c.dollar }, ); try lines.append(arena, .{ .text = row_text, .style = th.styleFor(c.style) }); } } // ── Hidden count ── if (cv.added_count > 0 or cv.removed_count > 0) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const hidden_text = try std.fmt.allocPrint( arena, " ({d} added, {d} removed since {s} — hidden)", .{ cv.added_count, cv.removed_count, then_str }, ); try lines.append(arena, .{ .text = hidden_text, .style = th.mutedStyle() }); } // ── Footer hint ── try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Esc or 'c' to return to timeline", .style = th.mutedStyle(), }); return lines.toOwnedSlice(arena); } // ── 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(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[1] = .{ .as_of_date = Date.fromYmd(2024, 3, 15), .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); 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); 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(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[1] = .{ .as_of_date = Date.fromYmd(2024, 3, 15), .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(2024, 3, 13), .net_worth = 900, .liquid = 600, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[1] = .{ .as_of_date = Date.fromYmd(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[2] = .{ .as_of_date = Date.fromYmd(2024, 3, 15), .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); var joined: std.ArrayList(u8) = .empty; for (lines) |l| { try joined.appendSlice(a, l.text); try joined.append(a, '\n'); } const text = joined.items; 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); const d_new = std.mem.lastIndexOf(u8, text, "2024-03-15") orelse return error.TestExpectedMatch; const d_old = std.mem.lastIndexOf(u8, text, "2024-03-13") 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(2024, 3, 15), .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); } test "renderHistoryLinesFull: cursor highlights selected row" { 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(2024, 3, 13), .net_worth = 900, .liquid = 600, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[1] = .{ .as_of_date = Date.fromYmd(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; pts[2] = .{ .as_of_date = Date.fromYmd(2024, 3, 15), .net_worth = 1100, .liquid = 800, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, }; const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a }; const rows = try rowsFromSeries(a, series, .daily); // Two rows selected (idx 0 and 2), cursor on idx 1. const result = try renderHistoryLinesFull( a, th, series, .liquid, .daily, rows, 1, .{ 0, 2 }, ); try testing.expectEqual(@as(usize, 3), result.table_row_count); try testing.expect(result.table_first_line > 0); // Check that the first row (selected, not cursor) has the marker // character in its text at the expected position. const first_row_line = result.lines[result.table_first_line]; try testing.expect(std.mem.indexOf(u8, first_row_line.text, "* ") != null); // Third row is selected too. const third_row_line = result.lines[result.table_first_line + 2]; try testing.expect(std.mem.indexOf(u8, third_row_line.text, "* ") != null); // Second row (cursor) has no marker — just the cursor style. const second_row_line = result.lines[result.table_first_line + 1]; try testing.expect(std.mem.indexOf(u8, second_row_line.text, "* ") == null); } // ── renderCompareLines tests ────────────────────────────────── test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const th = theme.default_theme; const symbols = [_]compare_view.SymbolChange{ .{ .symbol = "FOO", .price_then = 100, .price_now = 110, .shares_held_throughout = 10, .pct_change = 0.10, .dollar_change = 100, .style = .positive, }, }; const cv = compare_view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = compare_view.buildTotalsRow(10_000, 10_500), .symbols = @constCast(&symbols), .held_count = 1, .added_count = 1, .removed_count = 0, }; const lines = try renderCompareLines(a, th, cv); var saw_header = false; var saw_totals = false; var saw_row = false; var saw_hidden = false; var saw_footer = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Portfolio comparison: 2024-01-15 → 2024-03-15") != null) saw_header = true; if (std.mem.indexOf(u8, l.text, "Liquid:") != null and std.mem.indexOf(u8, l.text, "$10,000.00") != null) saw_totals = true; if (std.mem.indexOf(u8, l.text, "FOO") != null and std.mem.indexOf(u8, l.text, "+10.00%") != null) saw_row = true; if (std.mem.indexOf(u8, l.text, "1 added, 0 removed since 2024-01-15") != null) saw_hidden = true; if (std.mem.indexOf(u8, l.text, "Esc") != null) saw_footer = true; } try testing.expect(saw_header); try testing.expect(saw_totals); try testing.expect(saw_row); try testing.expect(saw_hidden); try testing.expect(saw_footer); } test "renderCompareLines: live-now shows 'today' not a date" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const th = theme.default_theme; const cv = compare_view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = true, .liquid = compare_view.buildTotalsRow(100, 105), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; const lines = try renderCompareLines(a, th, cv); var saw_today = false; var saw_no_symbols = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "→ today") != null) saw_today = true; if (std.mem.indexOf(u8, l.text, "No symbols held throughout") != null) saw_no_symbols = true; } try testing.expect(saw_today); try testing.expect(saw_no_symbols); } test "renderCompareLines: no hidden line when no add/remove" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); const a = arena.allocator(); const th = theme.default_theme; const cv = compare_view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = compare_view.buildTotalsRow(100, 100), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; const lines = try renderCompareLines(a, th, cv); for (lines) |l| { try testing.expect(std.mem.indexOf(u8, l.text, "hidden") == null); } } // Keep refAllDeclsRecursive happy test { _ = snapshot; }