//! 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 = @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; /// Composite key for `App.history_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(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.loadTimeline(app.io, 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: cascading → daily → weekly → monthly → cascading. pub fn cycleResolution(app: *App) void { app.history_resolution = switch (app.history_resolution orelse { // null means "default" — i.e. cascading. Step to daily. app.history_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(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, app) catch return false; if (app.history_cursor >= rows.len) return false; const row = rows[app.history_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 (app.history_expanded_buckets.contains(key)) { _ = app.history_expanded_buckets.remove(key); } else { app.history_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(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; // 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(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; // 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(app); return; } // 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.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; 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 the App. From this point onwards the // App owns them and clearCompareView handles teardown. clearCompareView(app); app.history_compare_view = cv_with_labels; 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. /// /// `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, 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 &.{}; // 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 = app.history_resolution; const use_cascading = explicit == null or explicit.? == .cascading; if (!use_cascading) { return collectFlatTableRows(arena, app, series, explicit.?); } return collectCascadingTableRows(arena, 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, 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 = app.history_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, app, 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, app: *const App, 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 = app.history_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, app, 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 == null) return null; const summary = app.portfolio_summary orelse return null; const liquid = summary.total_value; const illiquid = app.portfolio.?.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 == null) return null; const summary = app.portfolio_summary orelse return null; const liquid = summary.total_value; const illiquid = app.portfolio.?.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(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. // 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 table_header = try std.fmt.allocPrint( arena, " Recent snapshots {s} (j/k: move, enter: expand/collapse, 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; "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", .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, ) ![]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_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 = " 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; } 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); 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))); }