//! 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: see the `Action` enum + `default_bindings` 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 = @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 framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Tab-local action enum ───────────────────────────────────── // // History tab keybinds: // - `m` : cycle the focused metric (liquid -> // illiquid -> net_worth). // - `t` : cycle the resolution (cascading -> // daily -> weekly -> monthly -> cascading). // - Enter : expand/collapse the bucket at the cursor // (only meaningful for non-daily, non-live // rows with finer-tier breakdown). // // History tab keybinds: // - `m` : cycle the focused metric (liquid -> // illiquid -> net_worth). // - `t` : cycle the resolution (cascading -> // daily -> weekly -> monthly -> cascading). // - Enter : expand/collapse the bucket at the cursor // (only meaningful for non-daily, non-live // rows with finer-tier breakdown). // - `s` / space : toggle inclusion of the cursor row in // the compare-selection set (0, 1, or 2). // - `c` : commit compare (requires 2 rows // selected) or exit an active compare view. // - Esc : cancel compare view or clear pending // selections. pub const Action = enum { /// Toggle expansion of the bucket at the cursor. No-op for /// daily, live, or no-children rows. Bound to Enter. expand_collapse, /// Cycle the focused metric: liquid -> illiquid -> net_worth -> liquid. metric_next, /// Cycle the resolution: cascading -> daily -> weekly -> monthly -> cascading. resolution_next, /// Toggle inclusion of the cursor row in the compare selection set. /// Disabled while a compare view is up. Bound to `s` and space. compare_select, /// Run compare if exactly 2 rows are selected (otherwise status /// hint), or exit an active compare view. Bound to `c`. compare_commit, /// Cancel compare: drop active compare view if up, else clear /// pending selections. No-op when nothing is active. Bound to Esc. compare_cancel, }; // ── Tab-private state ───────────────────────────────────────── /// Backing resources for the history tab's active compare view. /// /// Both endpoints are tracked independently. Snapshot endpoints own /// their `SnapshotSide` (which includes the snapshot bytes the /// HoldingMap keys borrow from). Live endpoints own only a /// `HoldingMap`; the map's keys borrow from `App.portfolio`, which /// outlives this struct. /// /// Deinit order is important: the `CompareView` must be deinit'd /// before these resources, because the view's `symbols` slice contains /// `SymbolChange.symbol` strings that borrow from one of the maps /// (the "then" side, per `buildCompareView`). pub const CompareResources = struct { then_snap: ?compare_core.SnapshotSide = null, now_snap: ?compare_core.SnapshotSide = null, then_live_map: ?compare_view.HoldingMap = null, now_live_map: ?compare_view.HoldingMap = null, pub fn deinit(self: *CompareResources, allocator: std.mem.Allocator) void { if (self.then_snap) |*s| s.deinit(allocator); if (self.now_snap) |*s| s.deinit(allocator); if (self.then_live_map) |*m| m.deinit(); if (self.now_live_map) |*m| m.deinit(); } }; pub const State = struct { /// Whether `activate` has populated the timeline (or set /// `disabled`). Distinct from `timeline != null` because /// failed/empty fetches still mark loaded. loaded: bool = false, /// Loaded timeline + series (owned). Null when not loaded /// or when the load failed. tl: ?history.LoadedTimeline = null, /// Cursor position in the recent-snapshots table. cursor: usize = 0, /// Compare-mode selection slots. Up to two row indices may /// be selected; commit-compare requires exactly two. selections: [2]?usize = .{ null, null }, /// Active compare view. When non-null, the history tab /// renders a `CompareView` instead of the timeline. compare_view: ?compare_view.CompareView = null, /// Backing storage for `compare_view`. Owned by State; /// freed via `clearCompareView`. compare_resources: ?CompareResources = null, /// Set during `buildStyledLines`; consumed by the key /// handler's cursor-visibility logic and click hit-tests. /// First display line of the recent-snapshots table. table_first_line: usize = 0, /// Number of rows in the recent-snapshots table (post-aggregation). table_row_count: usize = 0, /// Currently displayed metric (cycles via `metric_next`). metric: timeline.Metric = .liquid, /// Forced resolution for the history table + chart. Null means /// "use the cascading default chosen by `selectResolution`"; /// the user cycles this with `resolution_next` ('t' by default). resolution: ?timeline.Resolution = null, /// Per-bucket expansion set. Keyed by `BucketKey` (tier + days) /// to disambiguate edge-aligned parents and children. Initialized /// in `init` (requires an allocator). // SAFETY: overwritten by `init()` before any read; the framework // contract guarantees `init` runs before `activate`/draw paths. expanded_buckets: std.AutoHashMap(BucketKey, void) = undefined, }; // ── Tab framework contract ──────────────────────────────────── pub const meta: framework.TabMeta(Action) = .{ .label = "History", .default_bindings = &.{ .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, .{ .action = .metric_next, .key = .{ .codepoint = 'm' } }, .{ .action = .resolution_next, .key = .{ .codepoint = 't' } }, .{ .action = .compare_select, .key = .{ .codepoint = 's' } }, .{ .action = .compare_select, .key = .{ .codepoint = vaxis.Key.space } }, .{ .action = .compare_commit, .key = .{ .codepoint = 'c' } }, .{ .action = .compare_cancel, .key = .{ .codepoint = vaxis.Key.escape } }, }, .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .expand_collapse = "Expand/collapse bucket", .metric_next = "Cycle metric", .resolution_next = "Cycle resolution", .compare_select = "Toggle compare selection", .compare_commit = "Compare selected rows", .compare_cancel = "Cancel compare", }), .status_hints = &.{ .metric_next, .resolution_next, }, }; pub const tab = struct { pub const ActionT = Action; pub const StateT = State; pub fn init(state: *State, app: *App) !void { state.* = .{ .expanded_buckets = std.AutoHashMap(BucketKey, void).init(app.allocator), }; } pub fn deinit(state: *State, app: *App) void { freeLoaded(state, app); state.expanded_buckets.deinit(); state.* = .{}; } pub fn activate(state: *State, app: *App) !void { if (state.loaded) return; // History reads `app.portfolio.summary` and `.file`, // both populated synchronously by pd.load at App init. loadData(state, app); } pub const deactivate = framework.noopDeactivate(State); pub fn reload(state: *State, app: *App) !void { state.loaded = false; freeLoaded(state, app); loadData(state, app); } /// Drop cached timeline and compare view on portfolio reload. /// `state.tl` borrows symbol strings from the previous /// portfolio's memory, and `compare_resources.then_live_map` /// / `now_live_map` borrow keys from `app.portfolio` - all /// of which `pd.reload` is about to free. Drop them now. /// /// Deliberately does NOT eager-rebuild, even when this tab /// is active. The reload broadcast fires BEFORE pd.reload /// resets the portfolio arena; rebuilding now would store /// borrowed pointers that get freed seconds later. The /// orchestrator (portfolio_tab.reloadPortfolioFile) calls /// `app.loadTabData()` AFTER pd.reload completes, which /// re-activates the active tab against fresh data. /// /// Selections + cursor are reset because the row indices /// they refer to may no longer be valid after the new /// timeline is loaded (different snapshot count, etc.). pub fn onPortfolioReload(state: *State, app: *App) void { freeLoaded(state, app); state.loaded = false; state.cursor = 0; state.selections = .{ null, null }; state.table_first_line = 0; state.table_row_count = 0; state.expanded_buckets.clearRetainingCapacity(); } pub const tick = framework.noopTick(State); pub fn handleAction(state: *State, app: *App, action: Action) void { switch (action) { .expand_collapse => _ = toggleTierAtCursor(state, app), .metric_next => cycleMetric(state), .resolution_next => cycleResolution(state), .compare_select => { // Disabled while a compare view is up - Esc to return first. if (state.compare_view != null) return; if (state.table_row_count == 0) return; toggleSelection(state, app, state.cursor); }, .compare_commit => { // If a compare view is up, treat `c` as toggle-off // (mirrors Esc semantics on this tab). if (state.compare_view != null) { clearCompareView(state, app); clearSelections(state); app.setStatus(""); return; } commitCompare(state, app); }, .compare_cancel => { // Esc semantics: drop active compare view if up, // else clear pending selections, else no-op. if (state.compare_view != null) { clearCompareView(state, app); clearSelections(state); app.setStatus(""); return; } if (selectionCount(state) > 0) { clearSelections(state); app.setStatus(""); } }, } } /// History is disabled when no portfolio is loaded - the /// history/ subdirectory is derived from the portfolio path. /// Same predicate as analysis_tab and projections_tab. pub fn isDisabled(app: *App) bool { return app.portfolio.file == null; } /// Sync the cursor to the new scroll extreme. pub fn onScroll(state: *State, app: *App, where: framework.ScrollEdge) void { _ = app; switch (where) { .top => state.cursor = 0, .bottom => { if (state.table_row_count > 0) { state.cursor = state.table_row_count - 1; } }, } } /// Cursor navigation in the recent-snapshots table. Disabled /// during compare view mode (Esc/`c` returns first); also no-op /// when the table is empty (returns false so the framework /// falls through to viewport scroll). pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { if (state.compare_view != null) return false; if (state.table_row_count == 0) return false; stepCursor(&state.cursor, state.table_row_count, delta); ensureCursorVisible(state, &app.scroll_offset, app.visible_height); return true; } /// Mouse wheel: always scroll the viewport, never move the /// cursor. Keeps wheel-as-look-around and cursor-as-pointer /// distinct. The framework falls through to viewport scroll /// when this returns false. pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { _ = state; _ = app; _ = delta; return false; } /// Mouse handling: a left-click on a row moves the cursor and /// toggles tier expansion (no-op for non-tier rows). Returns /// `true` if the click landed on a data row in the recent- /// snapshots table; `false` otherwise (caller falls through /// to global mouse handling - wheel scroll, tab-bar clicks, /// etc.). Disabled during compare-view mode. pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { if (mouse.button != .left) return false; if (mouse.type != .press) return false; if (state.compare_view != null) return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; if (content_row < state.table_first_line) return false; if (state.table_row_count == 0) return false; const row_idx = content_row - state.table_first_line; if (row_idx >= state.table_row_count) return false; // Move the cursor to the clicked row, then try to toggle // if it's a tier header. Both outcomes consume the click. state.cursor = row_idx; _ = toggleTierAtCursor(state, app); return true; } }; fn stepCursor(cursor: *usize, row_count: usize, direction: isize) void { if (direction > 0) { if (row_count > 0 and cursor.* < row_count - 1) cursor.* += 1; } else { if (cursor.* > 0) cursor.* -= 1; } } fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void { const cursor_line = state.table_first_line + state.cursor; if (cursor_line < scroll_offset.*) scroll_offset.* = cursor_line; if (cursor_line >= scroll_offset.* + visible_height) { scroll_offset.* = cursor_line - visible_height + 1; } } /// Composite key for `State.expanded_buckets`. Keying by /// `bucket_start.days` alone collides on edge-aligned parents /// and children - e.g. yearly 2024 starts on 2024-01-01, and so /// does its child quarterly Q1 2024. Tagging by tier /// disambiguates so expanding the parent doesn't auto-expand /// the child. pub const BucketKey = struct { tier: timeline.Tier, days: i32, }; fn keyFor(b: timeline.TierBucket) BucketKey { return .{ .tier = b.tier, .days = b.bucket_start.days }; } fn keyForRow(row: TableRow) ?BucketKey { const t = row.tier orelse return null; const start = row.bucket_start orelse return null; return .{ .tier = t, .days = start.days }; } // ── Data loading ────────────────────────────────────────────── pub fn loadData(state: *State, app: *App) void { state.loaded = true; freeLoaded(state, app); const portfolio_path = app.anchorPath() orelse { app.setStatus("History tab requires a loaded portfolio"); return; }; state.tl = history.loadTimeline(app.io, app.allocator, portfolio_path) catch { app.setStatus("Failed to read history/ directory"); return; }; if (state.tl.?.loaded.snapshots.len == 0) { freeLoaded(state, app); app.setStatus("No snapshots in history/ (run: zfin snapshot)"); } } /// Release the loaded timeline (if any). pub fn freeLoaded(state: *State, app: *App) void { if (state.tl) |*tl| { tl.deinit(); state.tl = null; } clearCompareView(state, app); } /// Clear the compare-view state (selections are preserved). fn clearCompareView(state: *State, app: *App) void { if (state.compare_view) |*cv| { cv.deinit(app.allocator); state.compare_view = null; } if (state.compare_resources) |*res| { res.deinit(app.allocator); state.compare_resources = null; } } /// Cycle the displayed metric: liquid -> illiquid -> net_worth -> liquid. pub fn cycleMetric(state: *State) void { state.metric = switch (state.metric) { .liquid => .illiquid, .illiquid => .net_worth, .net_worth => .liquid, }; } /// Cycle resolution: cascading -> daily -> weekly -> monthly -> cascading. pub fn cycleResolution(state: *State) void { state.resolution = switch (state.resolution orelse { // null means "default" - i.e. cascading. Step to daily. state.resolution = .daily; return; }) { .cascading => .daily, .daily => .weekly, .weekly => .monthly, .monthly => null, // back to cascading default }; } /// If the cursor is on an expandable (non-daily, non-live) /// bucket row, toggle its expanded state. Returns true if a /// toggle happened (so the caller knows to consume the keypress /// and trigger a redraw). pub fn toggleTierAtCursor(state: *State, app: *App) bool { var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const rows = collectTableRows(arena, state, app) catch return false; if (state.cursor >= rows.len) return false; const row = rows[state.cursor]; // Only buckets with a finer tier are expandable. Live rows, // daily rows, and rows without bucket_start are not. if (!row.has_children) return false; const key = keyForRow(row) orelse return false; if (state.expanded_buckets.contains(key)) { _ = state.expanded_buckets.remove(key); } else { state.expanded_buckets.put(key, {}) catch return false; } return true; } // ── Compare selection model ────────────────────────────────── /// Returns the number of currently selected rows (0, 1, or 2). fn selectionCount(state: *const State) usize { var n: usize = 0; for (state.selections) |s| { if (s != null) n += 1; } return n; } /// Returns true if row `idx` is currently selected. fn isSelected(state: *const State, idx: usize) bool { for (state.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(state: *State, app: *App, idx: usize) void { // Already selected? Remove. for (&state.selections) |*slot| { if (slot.*) |v| { if (v == idx) { slot.* = null; setSelectionStatus(state, app); return; } } } // Find an empty slot. for (&state.selections) |*slot| { if (slot.* == null) { slot.* = idx; setSelectionStatus(state, 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(state: *State) void { state.selections = .{ null, null }; } fn setSelectionStatus(state: *const State, app: *App) void { const n = selectionCount(state); if (n == 0) { app.setStatus(""); return; } var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return; var buf: [128]u8 = undefined; const msg = formatSelectionStatus(&buf, n, c_keys[0]) catch return; app.setStatus(msg); } /// Format the selection-count status message. Pure function over /// (count, commit_key). Returns a slice of `buf`. pub fn formatSelectionStatus(buf: []u8, count: usize, commit_key: []const u8) std.fmt.BufPrintError![]const u8 { return switch (count) { 1 => std.fmt.bufPrint(buf, "Selected 1 row - select one more + press '{s}' to compare", .{commit_key}), 2 => std.fmt.bufPrint(buf, "Selected 2 rows - press '{s}' to compare", .{commit_key}), else => unreachable, }; } /// Format the "select N rows" status hint shown by `commitCompare` /// when the user presses commit with too few rows selected. Pure /// function over (count, select_key, commit_key). pub fn formatCompareNeedMore(buf: []u8, count: usize, select_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 { return if (count == 0) std.fmt.bufPrint(buf, "Select two rows with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key }) else std.fmt.bufPrint(buf, "Select one more row with '{s}' (or space), then press '{s}' to compare", .{ select_key, commit_key }); } /// Format the "Comparing - ... to return" status set when a /// compare view becomes active. Pure function over the two keys. pub fn formatCompareActiveStatus(buf: []u8, cancel_key: []const u8, commit_key: []const u8) std.fmt.BufPrintError![]const u8 { return std.fmt.bufPrint(buf, "Comparing - {s} or '{s}' to return to timeline", .{ cancel_key, commit_key }); } /// Format the parenthetical key hint shown in the recent-snapshots /// table header. Pure function over the resolved keys; allocates /// in `arena`. pub fn formatTableHeaderHint( arena: std.mem.Allocator, j_key: []const u8, k_key: []const u8, enter_key: []const u8, select_key: []const u8, commit_key: []const u8, ) ![]const u8 { return std.fmt.allocPrint( arena, "({s}/{s}: move, {s}: expand/collapse, {s}/space: select, {s}: compare)", .{ j_key, k_key, enter_key, select_key, commit_key }, ); } /// Format the footer hint shown at the bottom of an active compare /// view. Pure function; allocates in `arena`. pub fn formatCompareFooterHint( arena: std.mem.Allocator, cancel_key: []const u8, commit_key: []const u8, ) ![]const u8 { return std.fmt.allocPrint(arena, " {s} or '{s}' to return to timeline", .{ cancel_key, commit_key }); } // ── 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(state: *State, app: *App) void { const sel_count = selectionCount(state); if (sel_count < 2) { var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const s_keys = app.keysForTabAction(arena, "history", "compare_select") catch return; const c_keys = app.keysForTabAction(arena, "history", "compare_commit") catch return; var buf: [192]u8 = undefined; const msg = formatCompareNeedMore(&buf, sel_count, s_keys[0], c_keys[0]) catch return; app.setStatus(msg); return; } // At this point both slots are filled. const sel_a = state.selections[0].?; const sel_b = state.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(state, app, sel_a, sel_b) catch |err| { var msg_buf: [128]u8 = undefined; // Translate the most common failure modes into actionable // messages. We already short-circuit imported-only rows // inside `buildCompareFromSelections`, so reaching the // FileNotFound arm here means a snapshot the row claimed // to have got moved or deleted between row collection // and snapshot load. const msg = switch (err) { error.FileNotFound => "Compare failed: snapshot file is missing", else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed", }; app.setStatus(msg); clearCompareView(state, app); }; } /// Build + stash the compare view from two selected row indices. fn buildCompareFromSelections(state: *State, 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, state, app); if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) { app.setStatus("Stale selection - please re-select"); clearSelections(state); 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; // Imported-only rows have no on-disk snapshot - the historical // value came from `imported_values.srf`, not a real portfolio // snapshot. We can't build a per-symbol compare from that // (no lots, no per-symbol prices). Bail out with a friendly // status before we try (and fail) to open the snapshot file. if (older.imported_only or newer.imported_only) { app.setStatus("Cannot compare: imported-only history has no per-symbol detail"); clearSelections(state); return; } // Build up the resources + maps for each side. var resources: CompareResources = .{}; errdefer resources.deinit(app.allocator); const portfolio_path = app.anchorPath() orelse { app.setStatus("No portfolio loaded - can't build compare"); return error.PortfolioLoadFailed; }; const hist_dir = try history.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.io, 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.io, 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; // Opt-in: bring a snapshot "then" side into the current split basis // (mirrors the CLI). Skipped when "then" is the live portfolio, // which is already effective-share. No-op unless the split opt-in // is set in metadata.srf. if (!older.is_live) { compare_core.adjustThenForSplits(app.svc, app.allocator, then_map_ptr, portfolio_path, older.date, newer.date); } 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. // Bucket-aware labels: when either selected row was a tier // bucket (weekly/monthly/quarterly/yearly), render // "Q1 2025 (ended 2025-03-28)" instead of the bare ISO date. // Allocates into app.allocator; CompareView.deinit frees. var cv_with_labels = cv; cv_with_labels.then_label = try compare_view.buildBucketLabel( app.allocator, older.tier, older.bucket_start, older.date, older.is_live, ); cv_with_labels.now_label = try compare_view.buildBucketLabel( app.allocator, newer.tier, newer.bucket_start, newer.date, newer.is_live, ); // Commit: install both onto State. From this point onwards State // owns them and clearCompareView handles teardown. clearCompareView(state, app); state.compare_view = cv_with_labels; state.compare_resources = resources; const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel"); const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit"); var status_buf: [128]u8 = undefined; const msg = formatCompareActiveStatus(&status_buf, cancel_keys[0], commit_keys[0]) catch "Comparing - return to timeline"; app.setStatus(msg); } 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 }); } } // ── 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. /// /// `tier`, when set on a non-header row, identifies which tier /// the bucket belongs to (used to render a date label like /// "W of YYYY-MM-DD" / "Q1 YYYY") and to decide drill-down /// behavior. /// /// `imported_only` flags rows where illiquid/net_worth are /// unavailable (data came from imported_values.srf). Renderers /// substitute `—` for those cells. /// /// `expanded` and `has_children` drive the per-bucket drill-down. /// A non-daily bucket can be expanded (clicked / Enter on cursor) /// to reveal its child buckets at the next-finer tier. /// /// `indent` is how deep the row sits in the drill-down tree /// (0 = top-level, 1 = first drill-down level, etc.). The /// renderer uses this to indent the date column visually. /// /// `tier_header` is retained for API compatibility but no /// longer populated by the cascading collector - every row in /// the new model is a bucket row. 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, /// Deprecated - always null in the cascading collector. Kept /// for compatibility with older test fixtures. tier_header: ?TierHeader = null, tier: ?timeline.Tier = null, imported_only: bool = false, bucket_start: ?zfin.Date = null, bucket_end: ?zfin.Date = null, /// True when the user has expanded this bucket. Daily and /// imported_only-only buckets cannot be expanded. expanded: bool = false, /// True when this bucket has a finer-tier breakdown /// available (any non-daily tier has children). has_children: bool = false, /// Drill-down depth. 0 for top-level rows; >0 for rows /// emitted as children of an expanded bucket. indent: u8 = 0, }; /// Tier header metadata. Rendering displays a chevron + tier /// label + bucket count. pub const TierHeader = struct { tier: timeline.Tier, collapsed: bool, bucket_count: u32, }; /// 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, state: *const State, app: *const App) ![]TableRow { const timeline_opt = state.tl; if (timeline_opt == null) return &.{}; const series = timeline_opt.?.series; if (series.points.len == 0) return &.{}; // Resolution dispatch: // - explicit non-cascading: use the legacy flat-aggregation // path (preserves existing behavior for daily/weekly/monthly). // - explicit cascading OR null (default): build the tiered view. const explicit = state.resolution; const use_cascading = explicit == null or explicit.? == .cascading; if (!use_cascading) { return collectFlatTableRows(arena, app, series, explicit.?); } return collectCascadingTableRows(arena, state, app, series); } /// Legacy path: flat aggregation by single resolution. Preserved /// for `--resolution daily/weekly/monthly` invocations. fn collectFlatTableRows( arena: std.mem.Allocator, app: *const App, series: timeline.TimelineSeries, resolution: timeline.Resolution, ) ![]TableRow { 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); } /// New path: cascading aggregation. Produces a flat row list /// where each top-level bucket is its own row; expanded buckets /// are followed by their child buckets at the next-finer tier /// (recursively, so an expanded year reveals quarters which can /// reveal months which can reveal weeks which can reveal days). /// /// Daily rows for the last 14 days are top-level (no parent /// bucket - they're already at leaf granularity). fn collectCascadingTableRows( arena: std.mem.Allocator, state: *const State, app: *const App, series: timeline.TimelineSeries, ) ![]TableRow { const ts = try timeline.aggregateCascading(arena, series.points, app.today); // Live row (today's live state) - same as flat path. const live_opt = buildLiveRowFromCascading(app, ts.buckets); var list: std.ArrayList(TableRow) = .empty; try list.ensureTotalCapacity(arena, ts.buckets.len + 8); if (live_opt) |live| try list.append(arena, live); // Pre-compute Δs for the top-level buckets so each row // shows "this bucket vs. its older neighbor." const top_deltas = try timeline.computeBucketDeltas(arena, ts.buckets); for (ts.buckets, 0..) |b, idx| { const d = top_deltas[idx]; const expanded = state.expanded_buckets.contains(keyFor(b)); try list.append(arena, .{ .date = b.representative_date, .is_live = false, .liquid = b.liquid, .illiquid = b.illiquid, .net_worth = b.net_worth, .d_liquid = d.delta_liquid, .d_illiquid = d.delta_illiquid, .d_net_worth = d.delta_net_worth, .tier_header = null, .tier = b.tier, .imported_only = b.imported_only, .bucket_start = b.bucket_start, .bucket_end = b.bucket_end, .expanded = expanded, .has_children = timeline.finerTier(b.tier) != null, .indent = 0, }); if (expanded) { try emitChildren(arena, state, series.points, b, &list, 1); } } return list.toOwnedSlice(arena); } /// Recursively emit child rows for a parent bucket. Used both /// at top level and when a child is itself expanded. fn emitChildren( arena: std.mem.Allocator, state: *const State, series: []const timeline.TimelinePoint, parent: timeline.TierBucket, list: *std.ArrayList(TableRow), indent: u8, ) !void { const children = try timeline.childBuckets(arena, parent); if (children.len == 0) return; const child_deltas = try timeline.computeBucketDeltas(arena, children); // The oldest child gets a null Δ from `computeBucketDeltas` // because there's no older neighbor inside the local slice. // But within the wider series there usually IS an older // point (just before `parent.bucket_start`); use it so the // bottom row shows a meaningful Δ instead of greyed-out null. // Only the very-first data point in the entire series should // legitimately have a null Δ. const oldest_idx = children.len - 1; const oldest = children[oldest_idx]; const prior_opt = priorPointBefore(series, oldest.bucket_start); const oldest_delta: timeline.BucketDelta = if (prior_opt) |prior| blk: { const dl: ?f64 = oldest.liquid - prior.liquid; // Imported-only crossings still null out illiquid / // net_worth Δs. const cross_imported = oldest.imported_only or prior.source == .imported; const di: ?f64 = if (cross_imported) null else oldest.illiquid - prior.illiquid; const dn: ?f64 = if (cross_imported) null else oldest.net_worth - prior.net_worth; break :blk .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn }; } else child_deltas[oldest_idx]; for (children, 0..) |c, idx| { const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx]; const expanded = state.expanded_buckets.contains(keyFor(c)); try list.append(arena, .{ .date = c.representative_date, .is_live = false, .liquid = c.liquid, .illiquid = c.illiquid, .net_worth = c.net_worth, .d_liquid = d.delta_liquid, .d_illiquid = d.delta_illiquid, .d_net_worth = d.delta_net_worth, .tier_header = null, .tier = c.tier, .imported_only = c.imported_only, .bucket_start = c.bucket_start, .bucket_end = c.bucket_end, .expanded = expanded, .has_children = timeline.finerTier(c.tier) != null, .indent = indent, }); if (expanded and timeline.finerTier(c.tier) != null) { try emitChildren(arena, state, series, c, list, indent + 1); } } } /// Find the most recent series point with `as_of_date < target`. /// Returns null when `target` precedes every point in the series /// - i.e., this would be the very first data point overall, the /// only case where a null Δ is correct. /// /// `series` is date-ascending (built that way by /// `timeline.buildMergedSeries`); we walk backward from the end /// for an early exit on typical drill-down workloads where the /// target is recent. fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?timeline.TimelinePoint { var i: usize = series.len; while (i > 0) { i -= 1; if (series[i].as_of_date.days < target.days) return series[i]; } return null; } /// Build the live row for the cascading path. Uses the newest /// bucket (typically a daily bucket) as the comparison anchor. fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow { if (app.portfolio.file == null) return null; const summary = app.portfolio.summary orelse return null; const liquid = summary.total_value; const illiquid = app.portfolio.file.?.totalIlliquid(app.today); const net_worth = liquid + illiquid; var d_liquid: ?f64 = null; var d_illiquid: ?f64 = null; var d_net_worth: ?f64 = null; if (buckets.len > 0) { const b = buckets[0]; d_liquid = liquid - b.liquid; if (!b.imported_only) { d_illiquid = illiquid - b.illiquid; d_net_worth = net_worth - b.net_worth; } } return .{ .date = app.today, .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, }; } /// 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.file == null) return null; const summary = app.portfolio.summary orelse return null; const liquid = summary.total_value; const illiquid = app.portfolio.file.?.totalIlliquid(app.today); 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 = app.today, .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(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { // Compare mode short-circuits the timeline render. if (state.compare_view) |cv| { // Compare view doesn't populate table metadata; reset so the // cursor state remains sensible if the user exits back. const cancel_keys = try app.keysForTabAction(arena, "history", "compare_cancel"); const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit"); const footer = try formatCompareFooterHint(arena, cancel_keys[0], commit_keys[0]); return renderCompareLines(arena, app.theme, cv, footer); } const rows = try collectTableRows(arena, state, app); // Resolve key bindings for the table-header hint so the displayed // keys reflect the user's actual keymap (defaults or overridden). const enter_keys = try app.keysForTabAction(arena, "history", "expand_collapse"); const select_keys = try app.keysForTabAction(arena, "history", "compare_select"); const commit_keys = try app.keysForTabAction(arena, "history", "compare_commit"); const j_keys = try app.keysForGlobal(arena, .select_next); const k_keys = try app.keysForGlobal(arena, .select_prev); const table_hint = try formatTableHeaderHint( arena, j_keys[0], k_keys[0], enter_keys[0], select_keys[0], commit_keys[0], ); const result = try renderHistoryLinesFull( arena, app.theme, if (state.tl) |tl| tl.series else null, state.metric, state.resolution, rows, state.cursor, state.selections, table_hint, ); // Stash table metadata on State for the event handler's cursor- // visibility logic and the click hit-test. state.table_first_line = result.table_first_line; state.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) { state.cursor = 0; } else if (state.cursor >= result.table_row_count) { state.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 }, 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, /// Optional override for the parenthetical key hints in the /// "Recent snapshots" header. When null, falls back to the /// default-keymap literal. Pass an arena-allocated string from /// `buildStyledLines` for live-resolved keys. table_header_hint: ?[]const u8, ) !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. // For illiquid / net_worth, skip imported-only points so the // line is visually absent in the imported-only range rather // than hugging zero. var candles_list: std.ArrayList(zfin.Candle) = .empty; try candles_list.ensureTotalCapacity(arena, points.len); const skip_imported = (focus_metric == .illiquid) or (focus_metric == .net_worth); for (points) |p| { if (skip_imported and p.source == .imported) continue; const value = extractOne(p, focus_metric); try candles_list.append(arena, .{ .date = p.as_of_date, .open = value, .high = value, .low = value, .close = value, .adj_close = value, .volume = 0, }); } const candles = candles_list.items; try tui.renderBrailleToStyledLines(arena, &lines, candles, th); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── Recent snapshots table ─────────────────────────────────── // Resolve the displayed resolution label: explicit override // wins, otherwise default is cascading (matches the // rendering in `collectTableRows`). const resolution: timeline.Resolution = if (resolution_override) |r| r else .cascading; var rlabel_buf: [32]u8 = undefined; const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution); const hint_text = table_header_hint orelse "(j/k: move, enter: expand/collapse, s/space: select, c: compare)"; const table_header = try std.fmt.allocPrint( arena, " Recent snapshots {s} {s}", .{ rlabel, hint_text }, ); 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; "Date" itself indented 2 more chars so the column // text aligns with bucket date labels (which carry a 2-space // chevron-or-spacer prefix). 28-col slot matches `fmtTableRow`. const header_line = try std.fmt.allocPrint( arena, " {s:<28} {s:>31} {s:>31} {s:>31}", .{ " 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 (snapshot-to-snapshot Δ)", .style = th.headerStyle() }); const header_line = try std.fmt.allocPrint( arena, " {s:<12} {s:>18} {s:>10} {s:>10}", .{ "", "Δ", "%", "% / yr" }, ); 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; var abuf: [16]u8 = undefined; const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf); const text = try std.fmt.allocPrint( arena, " {s:<12} {s:>18} {s:>10} {s:>10}", .{ cells.label, cells.delta_str, cells.pct_str, cells.ann_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. /// /// All rows align column-for-column regardless of drill-down depth: /// the value cells (Liquid/Illiquid/Net Worth) sit at fixed column /// positions so their closing `)` characters line up vertically /// across the whole table. /// /// Drill-down depth is conveyed inside the date column only - by /// indenting the chevron+label within the 20-byte date slot. The /// trade-off: if a deeply-drilled label overflows 20 bytes (e.g. /// ` ▶ W of 2024-03-31`), the value columns shift right just /// for that row. We mitigate by capping the per-level indent at /// 2 columns and shrinking the slot use. fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 { // Legacy: tier_header rows (now unused). Kept so old test // fixtures still render something sensible. if (row.tier_header) |th| { const chevron: []const u8 = if (th.collapsed) "▶" else "▼"; return std.fmt.allocPrint( arena, " {s} {s} ({d})", .{ chevron, @tagName(th.tier), th.bucket_count }, ); } var date_buf: [40]u8 = undefined; var liq_cell_buf: [64]u8 = undefined; var ill_cell_buf: [64]u8 = undefined; var nw_cell_buf: [64]u8 = undefined; // Build the date cell content. Drill-down depth is shown by // leading spaces *inside* the date slot, NOT by indenting // the whole row - so values stay aligned column-for-column. const date_s: []const u8 = if (row.is_live) // Write into `date_buf` so the in-place padding below // can extend it. std.fmt.bufPrint(&date_buf, " today", .{}) catch " today" else if (row.tier) |t| blk: { var inner_buf: [32]u8 = undefined; // Bucket rows from cascading mode carry `bucket_start`; // legacy daily rows from flat-resolution don't, so we // fall back to `row.date` (the row's representative // date - same value as bucket_start for daily buckets). const start = row.bucket_start orelse row.date; const lbl = timeline.formatBucketLabel(&inner_buf, t, start); // Compute indent prefix: 2 spaces per indent level, capped // at 16 to keep the date cell from blowing past its 28-col // budget. (Trees go 4-5 levels max in practice.) const indent_pool = " "; // 16 spaces const indent_cols = @min(@as(usize, row.indent) * 2, indent_pool.len); const lead = indent_pool[0..indent_cols]; if (row.has_children) { const chev: []const u8 = if (row.expanded) "▼" else "▶"; break :blk std.fmt.bufPrint( &date_buf, "{s}{s} {s}", .{ lead, chev, lbl }, ) catch lbl; } // No expand chevron: 2 leading spaces (to align with // chevron-bearing rows at the same indent level). break :blk std.fmt.bufPrint( &date_buf, "{s} {s}", .{ lead, lbl }, ) catch lbl; } else blk: { break :blk std.fmt.bufPrint(&date_buf, " {f}", .{row.date}) catch "????-??-??"; }; const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width); const ill_cell = if (row.imported_only) fmt.centerDash(&ill_cell_buf, view.table_cell_width) else view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width); const nw_cell = if (row.imported_only) fmt.centerDash(&nw_cell_buf, view.table_cell_width) else view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width); const marker: []const u8 = if (selected) "* " else " "; // Pad date_s to a consistent *display* width of 28 columns // (wide enough to fit the deepest drilldown label, which is // ` ▶ W of YYYY-MM-DD` ~ 25 cols when fully drilled). // Zig's `{s: 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, footer_hint: []const u8, ) ![]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: [24]u8 = undefined; const then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_iso = compare_view.nowLabel(cv, &now_buf); // Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)") // when the caller supplied them; fall back to plain ISO dates. const then_str = cv.then_label orelse then_iso; const now_str = cv.now_label orelse now_iso; 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 = footer_hint, .style = th.mutedStyle(), }); return lines.toOwnedSlice(arena); } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; const Date = zfin.Date; const snapshot = @import("../models/snapshot.zig"); test "formatSelectionStatus: count 1 includes commit key" { var buf: [128]u8 = undefined; const msg = try formatSelectionStatus(&buf, 1, "c"); try testing.expectEqualStrings("Selected 1 row - select one more + press 'c' to compare", msg); } test "formatSelectionStatus: count 2 includes commit key" { var buf: [128]u8 = undefined; const msg = try formatSelectionStatus(&buf, 2, "X"); try testing.expectEqualStrings("Selected 2 rows - press 'X' to compare", msg); } test "formatCompareNeedMore: count 0 says 'two rows'" { var buf: [192]u8 = undefined; const msg = try formatCompareNeedMore(&buf, 0, "s", "c"); try testing.expectEqualStrings("Select two rows with 's' (or space), then press 'c' to compare", msg); } test "formatCompareNeedMore: count 1 says 'one more row'" { var buf: [192]u8 = undefined; const msg = try formatCompareNeedMore(&buf, 1, "x", "y"); try testing.expectEqualStrings("Select one more row with 'x' (or space), then press 'y' to compare", msg); } test "formatCompareActiveStatus: includes both keys" { var buf: [128]u8 = undefined; const msg = try formatCompareActiveStatus(&buf, "Esc", "c"); try testing.expectEqualStrings("Comparing - Esc or 'c' to return to timeline", msg); } test "formatTableHeaderHint: includes all five keys" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const msg = try formatTableHeaderHint(arena, "j", "k", "enter", "s", "c"); try testing.expectEqualStrings("(j/k: move, enter: expand/collapse, s/space: select, c: compare)", msg); } test "formatTableHeaderHint: respects rebound keys" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const msg = try formatTableHeaderHint(arena, "down", "up", "space", "x", "y"); try testing.expectEqualStrings("(down/up: move, space: expand/collapse, x/space: select, y: compare)", msg); } test "formatCompareFooterHint: standard format" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const msg = try formatCompareFooterHint(arena, "Esc", "c"); try testing.expectEqualStrings(" Esc or 'c' to return to timeline", msg); } 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.indexOf(u8, l.text, "Change") != null) 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 }, null, ); 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, " Esc or 'c' to return to timeline"); 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 date with (live) marker" { 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, " Esc or 'c' to return to timeline"); var saw_live_marker = false; var saw_no_symbols = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "2024-03-15 (live)") != null) saw_live_marker = true; if (std.mem.indexOf(u8, l.text, "No symbols held throughout") != null) saw_no_symbols = true; } try testing.expect(saw_live_marker); 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, " Esc or 'c' to return to timeline"); for (lines) |l| { try testing.expect(std.mem.indexOf(u8, l.text, "hidden") == null); } } // Keep refAllDeclsRecursive happy test { _ = snapshot; } test "renderCompareLines: bucket labels override ISO dates in header" { 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(2025, 3, 28), .now_date = Date.fromYmd(2026, 5, 8), .days_between = 406, .now_is_live = false, .liquid = compare_view.buildTotalsRow(6_000_000, 8_000_000), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, .then_label = "Q1 2025 (ended 2025-03-28)", .now_label = null, }; const lines = try renderCompareLines(a, th, cv, " Esc or 'c' to return to timeline"); var found_header = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Q1 2025 (ended 2025-03-28)") != null and std.mem.indexOf(u8, l.text, "-> 2026-05-08") != null) { found_header = true; break; } } try testing.expect(found_header); } test "priorPointBefore: returns null for the very first data point" { const points = [_]timeline.TimelinePoint{ .{ .as_of_date = Date.fromYmd(2014, 7, 3), .net_worth = 1_280_000, .liquid = 1_280_000, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported, }, .{ .as_of_date = Date.fromYmd(2015, 7, 3), .net_worth = 1_500_000, .liquid = 1_500_000, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported, }, }; // Target preceding all data -> null. try testing.expectEqual(@as(?timeline.TimelinePoint, null), priorPointBefore(&points, Date.fromYmd(2014, 1, 1))); // Target after first -> finds the first point. const got = priorPointBefore(&points, Date.fromYmd(2015, 1, 1)); try testing.expect(got != null); try testing.expect(got.?.as_of_date.eql(Date.fromYmd(2014, 7, 3))); // Target after both -> finds the latest. const got2 = priorPointBefore(&points, Date.fromYmd(2026, 1, 1)); try testing.expect(got2 != null); try testing.expect(got2.?.as_of_date.eql(Date.fromYmd(2015, 7, 3))); }