From 0aabdfb4f1464033f504d4ea98afefe9473f0dcb Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 1 May 2026 10:27:25 -0700 Subject: [PATCH] tui implementation of compare mode --- src/commands/compare.zig | 1424 +++++++++++--------------------------- src/compare.zig | 301 ++++++++ src/history.zig | 219 ++++++ src/tui.zig | 131 +++- src/tui/history_tab.zig | 927 +++++++++++++++++++++++-- src/tui/keybinds.zig | 19 + src/views/compare.zig | 214 +++++- 7 files changed, 2143 insertions(+), 1092 deletions(-) create mode 100644 src/compare.zig diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 86499fe..8db48ec 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -32,12 +32,18 @@ //! and later available dates to stderr and exit non-zero — we don't //! silently snap, because the user should pick which direction. //! -//! ## Semantics +//! ## Structure //! -//! See `src/views/compare.zig` for the full rationale. In summary: -//! raw liquid delta (includes flows), per-symbol price-only % change, -//! dollar impact at `min(shares)` (held-throughout floor), symbols in -//! both snapshots only. +//! Most of the work happens elsewhere: +//! - `src/history.zig` — single-snapshot IO (loadSnapshotAt, +//! findNearestSnapshot) +//! - `src/compare.zig` — Side-loading + aggregation +//! - `src/views/compare.zig` — pure view model +//! +//! This file owns the CLI-specific pieces: arg parsing, the +//! live-portfolio pipeline (fetch prices + build summary), the +//! stderr-to-user "nearest snapshot" suggestions, and the ANSI +//! renderer. const std = @import("std"); const zfin = @import("../root.zig"); @@ -45,9 +51,8 @@ const cli = @import("common.zig"); const fmt = cli.fmt; const Date = zfin.Date; const history_io = @import("../history.zig"); -const snapshot_model = @import("../models/snapshot.zig"); +const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); -const view_hist = @import("../views/history.zig"); pub const Error = error{ UnexpectedArg, @@ -141,211 +146,173 @@ pub fn run( // (two-date mode) or the live portfolio (single-date mode). Once // loaded, both sides are shaped identically — a HoldingMap + liquid // total — and feed a single comparison path below. - var then_side = try loadSnapshotSide(allocator, hist_dir, then_date); + var then_side = compare_core.loadSnapshotSide(allocator, hist_dir, then_date) catch |err| switch (err) { + error.FileNotFound => { + try suggestNearest(allocator, hist_dir, then_date); + return error.SnapshotNotFound; + }, + else => return err, + }; defer then_side.deinit(allocator); - var now_side = if (now_is_live) - try loadLiveSide(allocator, svc, portfolio_path, color) - else - try loadSnapshotSide(allocator, hist_dir, now_date); - defer now_side.deinit(allocator); + if (now_is_live) { + var now_live = try LiveSide.load(allocator, svc, portfolio_path, color); + defer now_live.deinit(allocator); - // ── Build view + render ────────────────────────────────── + try renderFromParts( + out, + color, + then_date, + now_date, + true, + then_side.liquid, + now_live.liquid, + &then_side.map, + &now_live.map, + allocator, + ); + } else { + var now_side = compare_core.loadSnapshotSide(allocator, hist_dir, now_date) catch |err| switch (err) { + error.FileNotFound => { + try suggestNearest(allocator, hist_dir, now_date); + return error.SnapshotNotFound; + }, + else => return err, + }; + defer now_side.deinit(allocator); + + try renderFromParts( + out, + color, + then_date, + now_date, + false, + then_side.liquid, + now_side.liquid, + &then_side.map, + &now_side.map, + allocator, + ); + } +} + +/// Build the view from two holdings maps + totals, then render. +/// Factored out so both the live and snapshot "now" paths share a +/// single call site. +fn renderFromParts( + out: *std.Io.Writer, + color: bool, + then_date: Date, + now_date: Date, + now_is_live: bool, + then_liquid: f64, + now_liquid: f64, + then_map: *const view.HoldingMap, + now_map: *const view.HoldingMap, + allocator: std.mem.Allocator, +) !void { var cv = try view.buildCompareView( allocator, then_date, now_date, now_is_live, - then_side.liquid, - now_side.liquid, - &then_side.map, - &now_side.map, + then_liquid, + now_liquid, + then_map, + now_map, ); defer cv.deinit(allocator); try renderCompare(out, color, cv); } -// ── Side loading ───────────────────────────────────────────── +// ── Live-portfolio side (CLI-only) ─────────────────────────── -/// One side of the comparison — aggregated per-symbol holdings plus the -/// liquid total for that point in time. Owns any backing resources -/// (snapshot bytes or live-portfolio state) that the map strings borrow -/// from, so the caller just calls `deinit` and doesn't need to know -/// which source the side came from. -const Side = struct { - map: view.HoldingMap, - liquid: f64, - resources: Resources, - - const Resources = union(enum) { - snapshot: LoadedSnapshot, - live: LiveResources, - }; - - fn deinit(self: *Side, allocator: std.mem.Allocator) void { - self.map.deinit(); - switch (self.resources) { - .snapshot => |*s| s.deinit(allocator), - .live => |*l| l.deinit(allocator), - } - } -}; - -/// Backing resources for the live-portfolio side: the parsed portfolio -/// file plus the derived summary/prices. Kept together so `Side.deinit` -/// can tear them down in one sweep. -const LiveResources = struct { +/// Owning bundle for the live-portfolio endpoint used by CLI +/// single-date mode. Fetches prices, builds a PortfolioSummary, and +/// aggregates the live stock lots into a HoldingMap. +/// +/// Not used by the TUI — the TUI uses its already-loaded portfolio +/// state directly and calls `compare_core.aggregateLiveStocks` inline. +const LiveSide = struct { loaded: cli.LoadedPortfolio, pf_data: cli.PortfolioData, prices: std.StringHashMap(f64), + map: view.HoldingMap, + liquid: f64, - fn deinit(self: *LiveResources, allocator: std.mem.Allocator) void { + fn load( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + color: bool, + ) !LiveSide { + var loaded_pf = cli.loadPortfolio(allocator, portfolio_path) orelse return error.PortfolioLoadFailed; + errdefer loaded_pf.deinit(allocator); + + if (loaded_pf.portfolio.lots.len == 0) { + try cli.stderrPrint("Portfolio is empty.\n"); + return error.PortfolioLoadFailed; + } + + var prices = std.StringHashMap(f64).init(allocator); + errdefer prices.deinit(); + + if (loaded_pf.syms.len > 0) { + var load_result = cli.loadPortfolioPrices(svc, loaded_pf.syms, &.{}, false, color); + defer load_result.deinit(); + var it = load_result.prices.iterator(); + while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + } + + var pf_data = cli.buildPortfolioData( + allocator, + loaded_pf.portfolio, + loaded_pf.positions, + loaded_pf.syms, + &prices, + svc, + ) catch |err| switch (err) { + error.NoAllocations, error.SummaryFailed => { + try cli.stderrPrint("Error computing portfolio summary.\n"); + return error.PortfolioLoadFailed; + }, + else => return err, + }; + errdefer pf_data.deinit(allocator); + + var map: view.HoldingMap = .init(allocator); + errdefer map.deinit(); + try compare_core.aggregateLiveStocks(&loaded_pf.portfolio, &prices, &map); + + return .{ + .loaded = loaded_pf, + .pf_data = pf_data, + .prices = prices, + .map = map, + .liquid = pf_data.summary.total_value, + }; + } + + fn deinit(self: *LiveSide, allocator: std.mem.Allocator) void { + self.map.deinit(); self.prices.deinit(); self.pf_data.deinit(allocator); self.loaded.deinit(allocator); } }; -/// Load the snapshot for `date` from `hist_dir` and build a Side from -/// its stock lots + liquid total. On miss, prints nearest-available -/// dates to stderr and returns `error.SnapshotNotFound`. -fn loadSnapshotSide( - allocator: std.mem.Allocator, - hist_dir: []const u8, - date: Date, -) !Side { - var loaded = try loadSnapshotOrSuggest(allocator, hist_dir, date); - errdefer loaded.deinit(allocator); +// ── Nearest-snapshot suggestion (stderr, CLI-only) ─────────── - var map: view.HoldingMap = .init(allocator); - errdefer map.deinit(); - try aggregateSnapshotStocks(&loaded.snap, &map); - - return .{ - .map = map, - .liquid = liquidFromSnapshot(&loaded.snap), - .resources = .{ .snapshot = loaded }, - }; -} - -/// Load the live portfolio, fetch prices, and build a Side from the -/// open stock lots + `PortfolioSummary.total_value` (which is the -/// liquid total — see `adjustForNonStockAssets` in analytics/valuation.zig). -fn loadLiveSide( - allocator: std.mem.Allocator, - svc: *zfin.DataService, - portfolio_path: []const u8, - color: bool, -) !Side { - var loaded_pf = cli.loadPortfolio(allocator, portfolio_path) orelse return error.PortfolioLoadFailed; - errdefer loaded_pf.deinit(allocator); - - if (loaded_pf.portfolio.lots.len == 0) { - try cli.stderrPrint("Portfolio is empty.\n"); - return error.PortfolioLoadFailed; - } - - var prices = std.StringHashMap(f64).init(allocator); - errdefer prices.deinit(); - - if (loaded_pf.syms.len > 0) { - var load_result = cli.loadPortfolioPrices(svc, loaded_pf.syms, &.{}, false, color); - defer load_result.deinit(); - var it = load_result.prices.iterator(); - while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; - } - - var pf_data = cli.buildPortfolioData( - allocator, - loaded_pf.portfolio, - loaded_pf.positions, - loaded_pf.syms, - &prices, - svc, - ) catch |err| switch (err) { - error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint("Error computing portfolio summary.\n"); - return error.PortfolioLoadFailed; - }, - else => return err, - }; - errdefer pf_data.deinit(allocator); - - var map: view.HoldingMap = .init(allocator); - errdefer map.deinit(); - try aggregateLiveStocks(&loaded_pf.portfolio, &prices, &map); - - return .{ - .map = map, - .liquid = pf_data.summary.total_value, - .resources = .{ .live = .{ - .loaded = loaded_pf, - .pf_data = pf_data, - .prices = prices, - } }, - }; -} - -// ── Snapshot loading ───────────────────────────────────────── - -/// Owned snapshot + its backing byte buffer. Deinit in reverse. -const LoadedSnapshot = struct { - snap: snapshot_model.Snapshot, - bytes: []u8, - - pub fn deinit(self: *LoadedSnapshot, allocator: std.mem.Allocator) void { - self.snap.deinit(allocator); - allocator.free(self.bytes); - } -}; - -/// Load the snapshot file at `hist_dir/-portfolio.srf`. On miss, -/// scan `hist_dir` for the nearest earlier and later snapshots and print -/// them to stderr so the user can pick, then return -/// `error.SnapshotNotFound`. Returns `error.NoSnapshotsAvailable` if -/// there are zero snapshots at all. -fn loadSnapshotOrSuggest( - allocator: std.mem.Allocator, - hist_dir: []const u8, - target: Date, -) !LoadedSnapshot { - // Build the canonical file path. - var date_buf: [10]u8 = undefined; - const date_str = target.format(&date_buf); - const filename = try std.fmt.allocPrint(allocator, "{s}-portfolio.srf", .{date_str}); - defer allocator.free(filename); - const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename }); - defer allocator.free(full_path); - - const bytes = std.fs.cwd().readFileAlloc(allocator, full_path, 16 * 1024 * 1024) catch |err| switch (err) { - error.FileNotFound => { - try suggestNearest(allocator, hist_dir, target); - return error.SnapshotNotFound; - }, - else => return err, - }; - errdefer allocator.free(bytes); - - const snap = history_io.parseSnapshotBytes(allocator, bytes) catch |err| { - try cli.stderrPrint("Error: failed to parse snapshot file: "); - try cli.stderrPrint(full_path); - try cli.stderrPrint("\n"); - return err; - }; - - return .{ .snap = snap, .bytes = bytes }; -} - -/// Scan `hist_dir` for `*-portfolio.srf` files, find the nearest earlier -/// and later dates relative to `target`, and print to stderr. Always -/// prints *something* — at minimum a "no snapshots available" message. +/// Print a "no snapshot for " message plus the nearest earlier +/// and later available dates to stderr. Wraps the pure +/// `history_io.findNearestSnapshot` with CLI-specific output. fn suggestNearest( allocator: std.mem.Allocator, hist_dir: []const u8, target: Date, ) !void { - const nearest = findNearestSnapshot(allocator, hist_dir, target) catch |err| { + const nearest = history_io.findNearestSnapshot(hist_dir, target) catch |err| { try cli.stderrPrint("Error scanning history directory: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\n"); @@ -372,171 +339,50 @@ fn suggestNearest( var d_buf: [10]u8 = undefined; const d_str = d.format(&d_buf); const diff = target.days - d.days; - const msg = try std.fmt.bufPrint(&buf, " earlier: {s} ({d} day{s} before)\n", .{ d_str, diff, pluralS(diff) }); + const msg = try std.fmt.bufPrint(&buf, " earlier: {s} ({d} day{s} before)\n", .{ d_str, diff, view.dayPlural(diff) }); try cli.stderrPrint(msg); } if (nearest.later) |d| { var d_buf: [10]u8 = undefined; const d_str = d.format(&d_buf); const diff = d.days - target.days; - const msg = try std.fmt.bufPrint(&buf, " later: {s} ({d} day{s} after)\n", .{ d_str, diff, pluralS(diff) }); + const msg = try std.fmt.bufPrint(&buf, " later: {s} ({d} day{s} after)\n", .{ d_str, diff, view.dayPlural(diff) }); try cli.stderrPrint(msg); } } -fn pluralS(n: i32) []const u8 { - return if (n == 1) "" else "s"; -} - -pub const Nearest = struct { - earlier: ?Date, - later: ?Date, -}; - -/// Scan `hist_dir` for `YYYY-MM-DD-portfolio.srf` files and return the -/// dates closest to `target` — the largest earlier-or-equal-minus-1 date -/// (strictly earlier than `target`) and the smallest strictly-later date. -/// Files whose name doesn't parse as `YYYY-MM-DD-portfolio.srf` are -/// silently ignored. -pub fn findNearestSnapshot( - allocator: std.mem.Allocator, - hist_dir: []const u8, - target: Date, -) !Nearest { - _ = allocator; // currently no heap use; kept for future-proofing - var dir = std.fs.cwd().openDir(hist_dir, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return .{ .earlier = null, .later = null }, - else => return err, - }; - defer dir.close(); - - var earlier: ?Date = null; - var later: ?Date = null; - - var it = dir.iterate(); - while (try it.next()) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, history_io.snapshot_suffix)) continue; - // Expect exactly "YYYY-MM-DD" + suffix. - const expected_len = 10 + history_io.snapshot_suffix.len; - if (entry.name.len != expected_len) continue; - const d = Date.parse(entry.name[0..10]) catch continue; - - if (d.days < target.days) { - if (earlier == null or d.days > earlier.?.days) earlier = d; - } else if (d.days > target.days) { - if (later == null or d.days < later.?.days) later = d; - } - // Exact hit shouldn't happen (we get here only on miss), but - // ignoring it is the right call either way — we're looking for - // neighbors of the missing date. - } - - return .{ .earlier = earlier, .later = later }; -} - -// ── Aggregation helpers ────────────────────────────────────── - -/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, -/// and group by symbol into `out_map`. Shares are summed; price is -/// taken from the first lot seen (all stock lots of a symbol share -/// the same `price` field in a given snapshot). -/// -/// `out_map` keys and values both borrow from the snapshot's backing -/// byte buffer (for keys) and from the stack (for values). Caller must -/// keep the snapshot alive as long as the map is used. -pub fn aggregateSnapshotStocks( - snap: *const snapshot_model.Snapshot, - out_map: *view.HoldingMap, -) !void { - for (snap.lots) |lot| { - if (!std.mem.eql(u8, lot.security_type, "Stock")) continue; - const price = lot.price orelse continue; - if (out_map.getPtr(lot.symbol)) |h| { - h.shares += lot.shares; - // price is already set from first-seen; leave it. - } else { - try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price }); - } - } -} - -/// Walk the live portfolio's stock lots, group by `priceSymbol()`, -/// and look up the current price from `prices`. Mirrors the snapshot -/// aggregation so the two sides are apples-to-apples. -pub fn aggregateLiveStocks( - portfolio: *const zfin.Portfolio, - prices: *const std.StringHashMap(f64), - out_map: *view.HoldingMap, -) !void { - const today = fmt.todayDate(); - for (portfolio.lots) |lot| { - if (lot.security_type != .stock) continue; - if (!lot.lotIsOpenAsOf(today)) continue; - const sym = lot.priceSymbol(); - const raw_price = prices.get(sym) orelse continue; - // Honor `price_ratio` the same way snapshot does via - // `lot.effectivePrice` (false = raw price, we want effective). - const eff_price = lot.effectivePrice(raw_price, false); - if (out_map.getPtr(sym)) |h| { - h.shares += lot.shares; - // Price should be identical across lots of the same symbol - // (same raw price × same price_ratio). Leave first-seen. - } else { - try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price }); - } - } -} - -/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not -/// present (old snapshots from before liquid/illiquid split — shouldn't -/// happen in practice). -pub fn liquidFromSnapshot(snap: *const snapshot_model.Snapshot) f64 { - for (snap.totals) |t| { - if (std.mem.eql(u8, t.scope, "liquid")) return t.value; - } - return 0.0; -} - -// ── Rendering ──────────────────────────────────────────────── - -/// Column widths, chosen so symbols up to 8 chars plus $999,999.99 -/// prices and ±999.99% pcts all fit. `display_symbol` for CUSIP-note -/// positions (e.g. "TGT2035") also fits. -const col = struct { - const symbol_w: usize = 8; - const price_w: usize = 10; // $999,999.99 - const pct_w: usize = 10; // +999.99% - const dollar_w: usize = 16; // +$99,999,999.99 with slack - const arrow: []const u8 = " → "; -}; +// ── ANSI rendering (CLI-only) ──────────────────────────────── +// +// Thin adapter: pulls pre-formatted cells from `views/compare.zig` +// and drops them into an ANSI-colored layout. Column widths, money +// formatting, and label pluralization all come from the view layer — +// this function owns only the styling mechanism (ANSI escapes) and +// the renderer-specific layout choices (leading indent, newline +// placement, two-color totals line). fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; const then_str = cv.then_date.format(&then_buf); - const now_str = if (cv.now_is_live) "today" else blk: { - _ = cv.now_date.format(&now_buf); - break :blk now_buf[0..10]; - }; + const now_str = view.nowLabel(cv, &now_buf); - // ── Header ── + // Header try cli.setFg(out, color, cli.CLR_HEADER); try cli.setBold(out, color); try out.print("Portfolio comparison: {s} → {s} ({d} day{s})\n", .{ then_str, now_str, cv.days_between, - pluralS(cv.days_between), + view.dayPlural(cv.days_between), }); try cli.reset(out, color); try out.print("\n", .{}); - // ── Totals line ── + // Totals line — two-color: muted "then → now", intent-colored delta/pct. try renderTotalsLine(out, color, cv.liquid); try out.print("\n", .{}); - // ── Per-symbol table ── + // Per-symbol table if (cv.held_count == 0) { try cli.setFg(out, color, cli.CLR_MUTED); try out.print("No symbols held throughout this period.\n", .{}); @@ -551,7 +397,7 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { } } - // ── Hidden count ── + // Hidden count if (cv.added_count > 0 or cv.removed_count > 0) { try out.print("\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); @@ -570,20 +416,16 @@ fn renderTotalsLine(out: *std.Io.Writer, color: bool, t: view.TotalsRow) !void { var delta_buf: [32]u8 = undefined; var pct_buf: [16]u8 = undefined; - const then_str = fmt.fmtMoneyAbs(&then_buf, t.then); - const now_str = fmt.fmtMoneyAbs(&now_buf, t.now); - const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, t.delta); - const pct_str = view_hist.fmtSignedPercentBuf(&pct_buf, t.pct); + const c = view.buildTotalsCells(t, &then_buf, &now_buf, &delta_buf, &pct_buf); - // Label try out.print("Liquid: ", .{}); - // Value then, arrow, value now — muted color + // "then → now" in muted color try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("{s}{s}{s}", .{ then_str, col.arrow, now_str }); + try out.print("{s}{s}{s}", .{ c.then, view.arrow, c.now }); try cli.reset(out, color); - // Separator spaces, then delta + pct in intent color - try cli.setStyleIntent(out, color, t.style); - try out.print(" {s} {s}\n", .{ delta_str, pct_str }); + // Delta + pct in intent color + try cli.setStyleIntent(out, color, c.style); + try out.print(" {s} {s}\n", .{ c.delta, c.pct }); try cli.reset(out, color); } @@ -593,265 +435,28 @@ fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void var pct_buf: [16]u8 = undefined; var dollar_buf: [32]u8 = undefined; - const then_str = fmt.fmtMoneyAbs(&then_buf, s.price_then); - const now_str = fmt.fmtMoneyAbs(&now_buf, s.price_now); - const pct_str = view_hist.fmtSignedPercentBuf(&pct_buf, s.pct_change); - const dollar_str = view_hist.fmtSignedMoneyBuf(&dollar_buf, s.dollar_change); + const c = view.buildSymbolRowCells(s, &then_buf, &now_buf, &pct_buf, &dollar_buf); - try out.print(" ", .{}); - // Symbol in bold-ish (just reset + regular color for now; bold risks - // clashing in CLI themes without bold support). - try out.print("{s:<8} ", .{s.symbol}); + // Leading indent + symbol in default color. + try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); + // "then → now" in muted color. try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("{s:>10}{s}{s:<10}", .{ then_str, col.arrow, now_str }); + try out.print(view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); try cli.reset(out, color); - try cli.setStyleIntent(out, color, s.style); - try out.print(" {s:>8} {s:>14}\n", .{ pct_str, dollar_str }); + // Delta/pct in intent color. + try cli.setStyleIntent(out, color, c.style); + try out.print(" " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); try cli.reset(out, color); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; +const snapshot_model = @import("../models/snapshot.zig"); -test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" { - var map: view.HoldingMap = .init(testing.allocator); - defer map.deinit(); - - const lots = [_]snapshot_model.LotRow{ - .{ - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "Roth", - .security_type = "Stock", - .shares = 100, - .open_price = 120, - .cost_basis = 12000, - .value = 15000, - .price = 150.0, - .quote_date = Date.fromYmd(2026, 4, 20), - }, - .{ - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "IRA", - .security_type = "Stock", - .shares = 50, - .open_price = 130, - .cost_basis = 6500, - .value = 7500, - .price = 150.0, - .quote_date = Date.fromYmd(2026, 4, 20), - }, - // Cash lot — should be filtered - .{ - .symbol = "CASH", - .lot_symbol = "CASH", - .account = "Roth", - .security_type = "Cash", - .shares = 1000, - .open_price = 0, - .cost_basis = 1000, - .value = 1000, - .price = null, - .quote_date = null, - }, - // Another stock - .{ - .symbol = "MSFT", - .lot_symbol = "MSFT", - .account = "Roth", - .security_type = "Stock", - .shares = 25, - .open_price = 380, - .cost_basis = 9500, - .value = 10000, - .price = 400.0, - .quote_date = Date.fromYmd(2026, 4, 20), - }, - }; - const totals = [_]snapshot_model.TotalRow{ - .{ .scope = "liquid", .value = 1_000_000 }, - .{ .scope = "net_worth", .value = 1_100_000 }, - .{ .scope = "illiquid", .value = 100_000 }, - }; - const snap = snapshot_model.Snapshot{ - .meta = .{ - .snapshot_version = 1, - .as_of_date = Date.fromYmd(2026, 4, 20), - .captured_at = 0, - .zfin_version = "test", - .stale_count = 0, - }, - .totals = @constCast(&totals), - .tax_types = &.{}, - .accounts = &.{}, - .lots = @constCast(&lots), - }; - - try aggregateSnapshotStocks(&snap, &map); - - try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT — not CASH - try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares); - try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price); - try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); -} - -test "liquidFromSnapshot: finds liquid scope" { - const totals = [_]snapshot_model.TotalRow{ - .{ .scope = "net_worth", .value = 1_100_000 }, - .{ .scope = "liquid", .value = 1_000_000 }, - .{ .scope = "illiquid", .value = 100_000 }, - }; - const snap = snapshot_model.Snapshot{ - .meta = .{ - .snapshot_version = 1, - .as_of_date = Date.fromYmd(2026, 4, 20), - .captured_at = 0, - .zfin_version = "test", - .stale_count = 0, - }, - .totals = @constCast(&totals), - .tax_types = &.{}, - .accounts = &.{}, - .lots = &.{}, - }; - try testing.expectEqual(@as(f64, 1_000_000), liquidFromSnapshot(&snap)); -} - -test "liquidFromSnapshot: returns 0 when no liquid scope" { - const totals = [_]snapshot_model.TotalRow{ - .{ .scope = "net_worth", .value = 1_100_000 }, - }; - const snap = snapshot_model.Snapshot{ - .meta = .{ - .snapshot_version = 1, - .as_of_date = Date.fromYmd(2026, 4, 20), - .captured_at = 0, - .zfin_version = "test", - .stale_count = 0, - }, - .totals = @constCast(&totals), - .tax_types = &.{}, - .accounts = &.{}, - .lots = &.{}, - }; - try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); -} - -test "findNearestSnapshot: empty dir returns both null" { - // Build a temp dir with no snapshot files. - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const path = try tmp.dir.realpathAlloc(testing.allocator, "."); - defer testing.allocator.free(path); - - const result = try findNearestSnapshot(testing.allocator, path, Date.fromYmd(2026, 4, 20)); - try testing.expectEqual(@as(?Date, null), result.earlier); - try testing.expectEqual(@as(?Date, null), result.later); -} - -test "findNearestSnapshot: non-existent dir returns both null" { - const result = try findNearestSnapshot( - testing.allocator, - "/tmp/zfin-compare-test-never-exists-12345", - Date.fromYmd(2026, 4, 20), - ); - try testing.expectEqual(@as(?Date, null), result.earlier); - try testing.expectEqual(@as(?Date, null), result.later); -} - -test "findNearestSnapshot: earlier and later around gap" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - // Create a few snapshot files spanning a gap around 2026-04-19. - try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-20-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-25-portfolio.srf", .data = "" }); - // Noise files that should be ignored. - try tmp.dir.writeFile(.{ .sub_path = "random.txt", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "rollup.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "bogus-date-portfolio.srf", .data = "" }); - - const path = try tmp.dir.realpathAlloc(testing.allocator, "."); - defer testing.allocator.free(path); - - const result = try findNearestSnapshot( - testing.allocator, - path, - Date.fromYmd(2026, 4, 19), - ); - try testing.expect(result.earlier != null); - try testing.expect(result.later != null); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 17).days), result.earlier.?.days); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 20).days), result.later.?.days); -} - -test "findNearestSnapshot: before earliest — only later set" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); - - const path = try tmp.dir.realpathAlloc(testing.allocator, "."); - defer testing.allocator.free(path); - - const result = try findNearestSnapshot( - testing.allocator, - path, - Date.fromYmd(2026, 1, 1), - ); - try testing.expectEqual(@as(?Date, null), result.earlier); - try testing.expect(result.later != null); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 15).days), result.later.?.days); -} - -test "findNearestSnapshot: after latest — only earlier set" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); - - const path = try tmp.dir.realpathAlloc(testing.allocator, "."); - defer testing.allocator.free(path); - - const result = try findNearestSnapshot( - testing.allocator, - path, - Date.fromYmd(2027, 1, 1), - ); - try testing.expect(result.earlier != null); - try testing.expectEqual(@as(?Date, null), result.later); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 17).days), result.earlier.?.days); -} - -test "findNearestSnapshot: exact hit — still returns neighbors" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "2026-04-20-portfolio.srf", .data = "" }); - - const path = try tmp.dir.realpathAlloc(testing.allocator, "."); - defer testing.allocator.free(path); - - // We don't call this function on exact hits in practice (the file - // read succeeds), but if we did, it should find the next-nearest - // on each side. - const result = try findNearestSnapshot( - testing.allocator, - path, - Date.fromYmd(2026, 4, 17), - ); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 15).days), result.earlier.?.days); - try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 20).days), result.later.?.days); -} +// Aggregation and liquid-from-snapshot tests moved to src/compare.zig. +// Snapshot-IO tests (findNearestSnapshot, loadSnapshotAt) moved to +// src/history.zig. This file keeps only CLI-surface tests. test "renderCompare: basic output includes expected elements" { // Build a minimal comparison view by hand. Symbols and dollar @@ -912,9 +517,9 @@ test "renderCompare: basic output includes expected elements" { test "renderCompare: two-snapshot mode shows real date, not 'today'" { const cv = view.CompareView{ - .then_date = Date.fromYmd(2026, 4, 20), - .now_date = Date.fromYmd(2026, 4, 30), - .days_between = 10, + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, @@ -928,7 +533,7 @@ test "renderCompare: two-snapshot mode shows real date, not 'today'" { try renderCompare(&stream, false, cv); const out = stream.buffered(); - try testing.expect(std.mem.indexOf(u8, out, "2026-04-20 → 2026-04-30") != null); + try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); try testing.expect(std.mem.indexOf(u8, out, "today") == null); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null); // No "hidden" line when both counts are zero @@ -937,8 +542,8 @@ test "renderCompare: two-snapshot mode shows real date, not 'today'" { test "renderCompare: 1-day diff uses singular 'day'" { const cv = view.CompareView{ - .then_date = Date.fromYmd(2026, 4, 20), - .now_date = Date.fromYmd(2026, 4, 21), + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 1, 16), .days_between = 1, .now_is_live = false, .liquid = view.buildTotalsRow(100, 100), @@ -970,9 +575,9 @@ test "renderCompare: only added positions (no removed)" { }, }; const cv = view.CompareView{ - .then_date = Date.fromYmd(2026, 4, 20), - .now_date = Date.fromYmd(2026, 4, 30), - .days_between = 10, + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(10000, 11000), .symbols = @constCast(&symbols), @@ -986,15 +591,14 @@ test "renderCompare: only added positions (no removed)" { try renderCompare(&stream, false, cv); const out = stream.buffered(); - // Hidden line should still appear (added>0 is enough) - try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2026-04-20 — hidden)") != null); + try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null); } test "renderCompare: negative totals delta" { const cv = view.CompareView{ - .then_date = Date.fromYmd(2026, 4, 20), - .now_date = Date.fromYmd(2026, 4, 30), - .days_between = 10, + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(1_000_000, 900_000), .symbols = &.{}, @@ -1013,7 +617,228 @@ test "renderCompare: negative totals delta" { try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); } -// ── Integration: fixture snapshots end-to-end ────────────────── +// ── run() entry-point validation tests ───────────────────────── + +fn makeTestSvc() zfin.DataService { + // Minimal in-memory config. `cache_dir` must be set; "/tmp" is fine + // since these tests never hit the cache. + const config = zfin.Config{ .cache_dir = "/tmp" }; + return zfin.DataService.init(testing.allocator, config); +} + +fn makeTestPortfolioPath(tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 { + const dir_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dir_path); + return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" }); +} + +test "run: zero args returns MissingDateArg" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const result = run(testing.allocator, &svc, pf, &.{}, false, &stream); + try testing.expectError(error.MissingDateArg, result); +} + +test "run: three args returns UnexpectedArg" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{ "2024-01-15", "2024-02-15", "2024-03-15" }; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.UnexpectedArg, result); +} + +test "run: bad date1 returns InvalidDate" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{"not-a-date"}; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.InvalidDate, result); +} + +test "run: valid date1 + bad date2 returns InvalidDate" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{ "2024-01-15", "2024/03/15" }; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.InvalidDate, result); +} + +test "run: same date twice returns SameDate" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{ "2024-01-15", "2024-01-15" }; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.SameDate, result); +} + +test "run: one date equal to today returns SameDate" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + var today_buf: [10]u8 = undefined; + const today_str = fmt.todayDate().format(&today_buf); + const args = [_][]const u8{today_str}; + + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.SameDate, result); +} + +test "run: single-date past-date with empty history returns SnapshotNotFound" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{"2020-01-01"}; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.SnapshotNotFound, result); +} + +test "run: single-date future-date rejected as InvalidDate" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{"2099-01-01"}; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.InvalidDate, result); +} + +test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [1024]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + // Intentionally reversed — verifies the swap happens without + // error (both dates will fail to load with SnapshotNotFound). + const args = [_][]const u8{ "2024-03-15", "2024-01-15" }; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.SnapshotNotFound, result); +} + +test "run: two-date happy path via fixtures" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const d1 = Date.fromYmd(2024, 1, 15); + const d2 = Date.fromYmd(2024, 3, 15); + + const lots_then = [_]snapshot_model.LotRow{ + .{ + .kind = "lot", + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Roth", + .security_type = "Stock", + .shares = 100, + .open_price = 120, + .cost_basis = 12_000, + .value = 15_000, + .price = 150.0, + .quote_date = d1, + }, + }; + const lots_now = [_]snapshot_model.LotRow{ + .{ + .kind = "lot", + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Roth", + .security_type = "Stock", + .shares = 100, + .open_price = 120, + .cost_basis = 12_000, + .value = 16_500, + .price = 165.0, + .quote_date = d2, + }, + }; + try writeFixtureSnapshot(hist_dir, testing.allocator, "2024-01-15-portfolio.srf", d1, 15_000, 15_000, &lots_then); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2024-03-15-portfolio.srf", d2, 16_500, 16_500, &lots_now); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{ "2024-01-15", "2024-03-15" }; + try run(testing.allocator, &svc, pf, &args, false, &stream); + + const out = stream.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); + try testing.expect(std.mem.indexOf(u8, out, "+10.00%") != null); +} fn writeFixtureSnapshot( dir: std.fs.Dir, @@ -1048,418 +873,3 @@ fn writeFixtureSnapshot( defer allocator.free(rendered); try dir.writeFile(.{ .sub_path = filename, .data = rendered }); } - -test "compare: detailed end-to-end roundtrip through fixture snapshots" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - - // Snapshot files live under /history/. - try tmp.dir.makePath("history"); - var hist_dir = try tmp.dir.openDir("history", .{}); - defer hist_dir.close(); - - const d1 = Date.fromYmd(2026, 4, 20); - const d2 = Date.fromYmd(2026, 4, 30); - - // "Then" snapshot: AAPL 100 @ $150, MSFT 50 @ $400, NFLX 10 @ $500, - // plus a Cash lot that must be ignored. - const then_lots = [_]snapshot_model.LotRow{ - .{ - .kind = "lot", - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "Roth", - .security_type = "Stock", - .shares = 100, - .open_price = 120, - .cost_basis = 12_000, - .value = 15_000, - .price = 150.0, - .quote_date = d1, - }, - .{ - .kind = "lot", - .symbol = "MSFT", - .lot_symbol = "MSFT", - .account = "Roth", - .security_type = "Stock", - .shares = 50, - .open_price = 380, - .cost_basis = 19_000, - .value = 20_000, - .price = 400.0, - .quote_date = d1, - }, - .{ - .kind = "lot", - .symbol = "NFLX", - .lot_symbol = "NFLX", - .account = "IRA", - .security_type = "Stock", - .shares = 10, - .open_price = 500, - .cost_basis = 5_000, - .value = 5_000, - .price = 500.0, - .quote_date = d1, - }, - .{ - .kind = "lot", - .symbol = "CASH", - .lot_symbol = "CASH", - .account = "Roth", - .security_type = "Cash", - .shares = 1_000, - .open_price = 0, - .cost_basis = 1_000, - .value = 1_000, - .price = null, - .quote_date = null, - }, - }; - try writeFixtureSnapshot( - hist_dir, - testing.allocator, - "2026-04-20-portfolio.srf", - d1, - 41_000.0, - 41_000.0, - &then_lots, - ); - - // "Now" snapshot: AAPL still 100 @ $165 (+10%), MSFT 50 @ $420 (+5%), - // NFLX dropped (removed), TSLA added 20 @ $250. - const now_lots = [_]snapshot_model.LotRow{ - .{ - .kind = "lot", - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "Roth", - .security_type = "Stock", - .shares = 100, - .open_price = 120, - .cost_basis = 12_000, - .value = 16_500, - .price = 165.0, - .quote_date = d2, - }, - .{ - .kind = "lot", - .symbol = "MSFT", - .lot_symbol = "MSFT", - .account = "Roth", - .security_type = "Stock", - .shares = 50, - .open_price = 380, - .cost_basis = 19_000, - .value = 21_000, - .price = 420.0, - .quote_date = d2, - }, - .{ - .kind = "lot", - .symbol = "TSLA", - .lot_symbol = "TSLA", - .account = "IRA", - .security_type = "Stock", - .shares = 20, - .open_price = 240, - .cost_basis = 4_800, - .value = 5_000, - .price = 250.0, - .quote_date = d2, - }, - }; - try writeFixtureSnapshot( - hist_dir, - testing.allocator, - "2026-04-30-portfolio.srf", - d2, - 42_500.0, - 42_500.0, - &now_lots, - ); - - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [8192]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{ "2026-04-20", "2026-04-30" }; - try run(testing.allocator, &svc, pf, &args, false, &stream); - const out = stream.buffered(); - - // Header - try testing.expect(std.mem.indexOf(u8, out, "Portfolio comparison: 2026-04-20 → 2026-04-30 (10 days)") != null); - - // Totals: 41000 -> 42500, +1500, +3.66% - try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); - try testing.expect(std.mem.indexOf(u8, out, "$41,000.00") != null); - try testing.expect(std.mem.indexOf(u8, out, "$42,500.00") != null); - try testing.expect(std.mem.indexOf(u8, out, "+$1,500.00") != null); - - // Per-symbol: AAPL +10%, MSFT +5% — both held. NFLX removed, TSLA added. - // Sort order: AAPL (10%) before MSFT (5%) - const aapl_idx = std.mem.indexOf(u8, out, "AAPL") orelse unreachable; - const msft_idx = std.mem.indexOf(u8, out, "MSFT") orelse unreachable; - try testing.expect(aapl_idx < msft_idx); - - // Added = 1 (TSLA), Removed = 1 (NFLX) - try testing.expect(std.mem.indexOf(u8, out, "(1 added, 1 removed since 2026-04-20 — hidden)") != null); - - // Non-stock (cash) never appears - try testing.expect(std.mem.indexOf(u8, out, "CASH") == null); - // Removed position never appears as a row - try testing.expect(std.mem.indexOf(u8, out, "NFLX") == null); - // Added position never appears as a row - try testing.expect(std.mem.indexOf(u8, out, "TSLA") == null); - - // Sanity: 2 held throughout - try testing.expect(std.mem.indexOf(u8, out, "(2 held throughout)") != null); -} - -// ── run() entry-point validation tests ───────────────────────── -// -// These exercise the top-level argument validation path in `run()`. -// The `svc` argument is a constructed-but-unused DataService (empty -// config), because the validation path returns before touching the -// service in every test below. - -fn makeTestSvc() zfin.DataService { - // Minimal in-memory config. `cache_dir` must be set; "/tmp" is fine - // since these tests never hit the cache. - const config = zfin.Config{ .cache_dir = "/tmp" }; - return zfin.DataService.init(testing.allocator, config); -} - -fn makeTestPortfolioPath(tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 { - // Construct a fake portfolio path pointing into the tmp dir. The - // file doesn't need to exist — we're exercising arg validation and - // snapshot-miss paths, both of which return before trying to read - // the portfolio. - const dir_path = try tmp.dir.realpathAlloc(allocator, "."); - defer allocator.free(dir_path); - return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" }); -} - -test "run: zero args returns MissingDateArg" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const result = run(testing.allocator, &svc, pf, &.{}, false, &stream); - try testing.expectError(error.MissingDateArg, result); -} - -test "run: three args returns UnexpectedArg" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{ "2026-04-20", "2026-04-25", "2026-04-30" }; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.UnexpectedArg, result); -} - -test "run: bad date1 returns InvalidDate" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{"not-a-date"}; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.InvalidDate, result); -} - -test "run: valid date1 + bad date2 returns InvalidDate" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{ "2026-04-20", "2026/04/30" }; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.InvalidDate, result); -} - -test "run: same date twice returns SameDate" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{ "2026-04-20", "2026-04-20" }; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.SameDate, result); -} - -test "run: one date equal to today returns SameDate" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - // Whatever today is, that's the arg. - var today_buf: [10]u8 = undefined; - const today_str = fmt.todayDate().format(&today_buf); - const args = [_][]const u8{today_str}; - - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.SameDate, result); -} - -test "run: two valid dates missing snapshots returns SnapshotNotFound (auto-swap path)" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - - // Build a portfolio path in the tmp dir — the history/ dir next to - // it won't exist, so the snapshot-load will miss. - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - // Intentionally reverse order — verifies the swap path runs without - // error (both dates fail to load with the same SnapshotNotFound). - const args = [_][]const u8{ "2026-04-30", "2026-04-20" }; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.SnapshotNotFound, result); -} - -test "run: single-date past-date returns SnapshotNotFound with no history" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - // A far-past date that can't match today — hits the - // single-date path → loadSnapshotSide → loadSnapshotOrSuggest miss. - const args = [_][]const u8{"2020-01-01"}; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.SnapshotNotFound, result); -} - -test "run: single-date future-date rejected as InvalidDate" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [1024]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - // A far-future date in single-date mode is nonsense — the "then" - // side would be a snapshot for that future date (which can't exist), - // so we reject at the arg-validation layer instead of stumbling - // through snapshot-not-found handling. - const args = [_][]const u8{"2099-01-01"}; - const result = run(testing.allocator, &svc, pf, &args, false, &stream); - try testing.expectError(error.InvalidDate, result); -} - -test "run: two-date happy path via fixtures" { - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - var svc = makeTestSvc(); - defer svc.deinit(); - - // Snapshot files must live under /history/. - try tmp.dir.makePath("history"); - var hist_dir = try tmp.dir.openDir("history", .{}); - defer hist_dir.close(); - - const d1 = Date.fromYmd(2026, 4, 20); - const d2 = Date.fromYmd(2026, 4, 30); - - const lots_then = [_]snapshot_model.LotRow{ - .{ - .kind = "lot", - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "Roth", - .security_type = "Stock", - .shares = 100, - .open_price = 120, - .cost_basis = 12_000, - .value = 15_000, - .price = 150.0, - .quote_date = d1, - }, - }; - const lots_now = [_]snapshot_model.LotRow{ - .{ - .kind = "lot", - .symbol = "AAPL", - .lot_symbol = "AAPL", - .account = "Roth", - .security_type = "Stock", - .shares = 100, - .open_price = 120, - .cost_basis = 12_000, - .value = 16_500, - .price = 165.0, - .quote_date = d2, - }, - }; - try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-04-20-portfolio.srf", d1, 15_000, 15_000, &lots_then); - try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-04-30-portfolio.srf", d2, 16_500, 16_500, &lots_now); - - const pf = try makeTestPortfolioPath(&tmp, testing.allocator); - defer testing.allocator.free(pf); - - var buf: [4096]u8 = undefined; - var stream = std.Io.Writer.fixed(&buf); - - const args = [_][]const u8{ "2026-04-20", "2026-04-30" }; - try run(testing.allocator, &svc, pf, &args, false, &stream); - - const out = stream.buffered(); - try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); - try testing.expect(std.mem.indexOf(u8, out, "+10.00%") != null); -} diff --git a/src/compare.zig b/src/compare.zig new file mode 100644 index 0000000..b257389 --- /dev/null +++ b/src/compare.zig @@ -0,0 +1,301 @@ +//! `src/compare.zig` — portfolio comparison composition layer. +//! +//! The CLI + TUI shared "compose two points in time" module. Loads a +//! snapshot into a `SnapshotSide` (aggregated per-symbol holdings + +//! liquid total), and provides the aggregation primitives that turn +//! either a parsed snapshot or the live portfolio into the +//! `view.HoldingMap` shape the view model consumes. +//! +//! Responsibility split: +//! - `src/history.zig` — snapshot IO (read file at a date) +//! - `src/compare.zig` — semantic composition (snapshot → +//! aggregated side; live aggregation) +//! - `src/views/compare.zig` — pure view model (build CompareView +//! from two holdings maps + totals) +//! - `src/commands/compare.zig` — CLI dispatch + live-side pipeline +//! + ANSI renderer +//! - `src/tui/history_tab.zig` — TUI selection UX + styled renderer +//! +//! This module is intentionally stateless and opinion-free about where +//! the "now" side comes from. The CLI wraps a one-shot live-portfolio +//! fetch into its own private Side type; the TUI uses the App's +//! already-loaded portfolio directly. Both feed the same view model. + +const std = @import("std"); +const zfin = @import("root.zig"); +const history_io = @import("history.zig"); +const snapshot_model = @import("models/snapshot.zig"); +const fmt = @import("format.zig"); +const view = @import("views/compare.zig"); + +pub const Date = zfin.Date; + +// ── Snapshot-side loading ──────────────────────────────────── + +/// A snapshot-backed side of a comparison: aggregated per-symbol +/// holdings + the liquid total for that snapshot, plus the backing +/// `LoadedSnapshot` the map's string keys borrow from. +/// +/// Call `deinit` to release everything in the right order (map before +/// snapshot, because the map's keys point into the snapshot bytes). +pub const SnapshotSide = struct { + map: view.HoldingMap, + liquid: f64, + loaded: history_io.LoadedSnapshot, + + pub fn deinit(self: *SnapshotSide, allocator: std.mem.Allocator) void { + self.map.deinit(); + self.loaded.deinit(allocator); + } +}; + +/// Load the snapshot for `date` from `hist_dir` and build a +/// `SnapshotSide` from its stock lots + liquid total. Returns +/// `error.FileNotFound` if the snapshot doesn't exist; the caller +/// decides how to surface that (CLI prints a "nearest date" hint; the +/// TUI only offers dates that are known to exist so it should never +/// hit this path). +pub fn loadSnapshotSide( + allocator: std.mem.Allocator, + hist_dir: []const u8, + date: Date, +) !SnapshotSide { + var loaded = try history_io.loadSnapshotAt(allocator, hist_dir, date); + errdefer loaded.deinit(allocator); + + var map: view.HoldingMap = .init(allocator); + errdefer map.deinit(); + try aggregateSnapshotStocks(&loaded.snap, &map); + + return .{ + .map = map, + .liquid = liquidFromSnapshot(&loaded.snap), + .loaded = loaded, + }; +} + +// ── Aggregation helpers ────────────────────────────────────── + +/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, +/// and group by symbol into `out_map`. Shares are summed; price is +/// taken from the first lot seen (all stock lots of a symbol share +/// the same `price` field in a given snapshot). +/// +/// `out_map` keys borrow from the snapshot's backing byte buffer. +/// Caller must keep the snapshot alive as long as the map is used. +pub fn aggregateSnapshotStocks( + snap: *const snapshot_model.Snapshot, + out_map: *view.HoldingMap, +) !void { + for (snap.lots) |lot| { + if (!std.mem.eql(u8, lot.security_type, "Stock")) continue; + const price = lot.price orelse continue; + if (out_map.getPtr(lot.symbol)) |h| { + h.shares += lot.shares; + // price is already set from first-seen; leave it. + } else { + try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price }); + } + } +} + +/// Walk the live portfolio's stock lots, group by `priceSymbol()`, +/// and look up the current price from `prices`. Mirrors the snapshot +/// aggregation so the two sides are apples-to-apples. +/// +/// `out_map` keys borrow from the portfolio's lot data (via +/// `priceSymbol()`). Caller must keep the portfolio alive as long as +/// the map is used. +pub fn aggregateLiveStocks( + portfolio: *const zfin.Portfolio, + prices: *const std.StringHashMap(f64), + out_map: *view.HoldingMap, +) !void { + const today = fmt.todayDate(); + for (portfolio.lots) |lot| { + if (lot.security_type != .stock) continue; + if (!lot.lotIsOpenAsOf(today)) continue; + const sym = lot.priceSymbol(); + const raw_price = prices.get(sym) orelse continue; + const eff_price = lot.effectivePrice(raw_price, false); + if (out_map.getPtr(sym)) |h| { + h.shares += lot.shares; + } else { + try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price }); + } + } +} + +/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not +/// present (old snapshots from before the liquid/illiquid split — +/// shouldn't happen in practice). +pub fn liquidFromSnapshot(snap: *const snapshot_model.Snapshot) f64 { + for (snap.totals) |t| { + if (std.mem.eql(u8, t.scope, "liquid")) return t.value; + } + return 0.0; +} + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" { + var map: view.HoldingMap = .init(testing.allocator); + defer map.deinit(); + + const lots = [_]snapshot_model.LotRow{ + .{ + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Roth", + .security_type = "Stock", + .shares = 100, + .open_price = 120, + .cost_basis = 12000, + .value = 15000, + .price = 150.0, + .quote_date = Date.fromYmd(2024, 3, 15), + }, + .{ + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "IRA", + .security_type = "Stock", + .shares = 50, + .open_price = 130, + .cost_basis = 6500, + .value = 7500, + .price = 150.0, + .quote_date = Date.fromYmd(2024, 3, 15), + }, + // Cash lot — must be filtered + .{ + .symbol = "CASH", + .lot_symbol = "CASH", + .account = "Roth", + .security_type = "Cash", + .shares = 1000, + .open_price = 0, + .cost_basis = 1000, + .value = 1000, + .price = null, + .quote_date = null, + }, + // Another stock + .{ + .symbol = "MSFT", + .lot_symbol = "MSFT", + .account = "Roth", + .security_type = "Stock", + .shares = 25, + .open_price = 380, + .cost_basis = 9500, + .value = 10000, + .price = 400.0, + .quote_date = Date.fromYmd(2024, 3, 15), + }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 3, 15), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = &.{}, + .tax_types = &.{}, + .accounts = &.{}, + .lots = @constCast(&lots), + }; + + try aggregateSnapshotStocks(&snap, &map); + + try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT — not CASH + try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares); + try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price); + try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); +} + +test "liquidFromSnapshot: finds liquid scope" { + const totals = [_]snapshot_model.TotalRow{ + .{ .scope = "net_worth", .value = 1100 }, + .{ .scope = "liquid", .value = 1000 }, + .{ .scope = "illiquid", .value = 100 }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 3, 15), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 1000), liquidFromSnapshot(&snap)); +} + +test "liquidFromSnapshot: returns 0 when no liquid scope" { + const totals = [_]snapshot_model.TotalRow{ + .{ .scope = "net_worth", .value = 1100 }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2024, 3, 15), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); +} + +test "loadSnapshotSide: happy path builds a SnapshotSide with aggregated holdings" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const snap_bytes = + \\#!srfv1 + \\#!created=1777589000 + \\kind::meta,snapshot_version:num:1,as_of_date::2024-03-15,captured_at:num:1777589000,zfin_version::test,stale_count:num:0 + \\kind::total,scope::net_worth,value:num:25000 + \\kind::total,scope::liquid,value:num:25000 + \\kind::total,scope::illiquid,value:num:0 + \\kind::lot,symbol::FOO,lot_symbol::FOO,account::Main,security_type::Stock,shares:num:100,open_price:num:120,cost_basis:num:12000,value:num:15000,price:num:150,quote_date::2024-03-15 + \\kind::lot,symbol::BAR,lot_symbol::BAR,account::Main,security_type::Stock,shares:num:50,open_price:num:180,cost_basis:num:9000,value:num:10000,price:num:200,quote_date::2024-03-15 + \\ + ; + try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes }); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + var side = try loadSnapshotSide(testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15)); + defer side.deinit(testing.allocator); + + try testing.expectEqual(@as(f64, 25000), side.liquid); + try testing.expectEqual(@as(u32, 2), side.map.count()); + try testing.expectEqual(@as(f64, 150), (side.map.get("FOO") orelse unreachable).price); + try testing.expectEqual(@as(f64, 50), (side.map.get("BAR") orelse unreachable).shares); +} + +test "loadSnapshotSide: missing file propagates FileNotFound" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const hist_dir = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(hist_dir); + + const result = loadSnapshotSide(testing.allocator, hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expectError(error.FileNotFound, result); +} diff --git a/src/history.zig b/src/history.zig index de6b13a..66e8d14 100644 --- a/src/history.zig +++ b/src/history.zig @@ -275,6 +275,94 @@ pub fn loadTimeline( }; } +// ── Single-snapshot loading ────────────────────────────────── + +/// Owned snapshot + its backing byte buffer. Strings inside `snap` +/// borrow from `bytes`, so the two must be freed in order: snap first +/// (releases its arrays), then bytes (releases the string storage). +/// `deinit` handles the order. +pub const LoadedSnapshot = struct { + snap: snapshot.Snapshot, + bytes: []u8, + + pub fn deinit(self: *LoadedSnapshot, allocator: std.mem.Allocator) void { + self.snap.deinit(allocator); + allocator.free(self.bytes); + } +}; + +/// Read + parse `history_dir/-portfolio.srf`. Returns +/// `error.FileNotFound` if the exact file doesn't exist; the caller is +/// responsible for deciding how to surface that (CLI prints a +/// suggestion via `findNearestSnapshot`, TUI just won't offer missing +/// dates as selectable rows). +pub fn loadSnapshotAt( + allocator: std.mem.Allocator, + history_dir: []const u8, + date: Date, +) !LoadedSnapshot { + var date_buf: [10]u8 = undefined; + const date_str = date.format(&date_buf); + const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix }); + defer allocator.free(filename); + const full_path = try std.fs.path.join(allocator, &.{ history_dir, filename }); + defer allocator.free(full_path); + + const bytes = try std.fs.cwd().readFileAlloc(allocator, full_path, 16 * 1024 * 1024); + errdefer allocator.free(bytes); + + const snap = try parseSnapshotBytes(allocator, bytes); + return .{ .snap = snap, .bytes = bytes }; +} + +/// Nearest-snapshot search result. `earlier` and `later` are +/// independently null if no snapshot exists on that side of `target`. +pub const Nearest = struct { + earlier: ?Date, + later: ?Date, +}; + +/// Scan `history_dir` for `YYYY-MM-DD-portfolio.srf` filenames and +/// return the closest date strictly earlier than `target` and the +/// closest date strictly later than `target`. Files whose name doesn't +/// parse as an ISO date + the snapshot suffix are ignored. +/// +/// Pure function — no stderr side effects. CLI callers that want to +/// print a "no snapshot for X; nearest is Y" hint compose this with +/// their own output pass. +pub fn findNearestSnapshot( + history_dir: []const u8, + target: Date, +) !Nearest { + var dir = std.fs.cwd().openDir(history_dir, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return .{ .earlier = null, .later = null }, + else => return err, + }; + defer dir.close(); + + var earlier: ?Date = null; + var later: ?Date = null; + + var it = dir.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, snapshot_suffix)) continue; + const expected_len = 10 + snapshot_suffix.len; + if (entry.name.len != expected_len) continue; + const d = Date.parse(entry.name[0..10]) catch continue; + + if (d.days < target.days) { + if (earlier == null or d.days > earlier.?.days) earlier = d; + } else if (d.days > target.days) { + if (later == null or d.days < later.?.days) later = d; + } + // Exact hit (d == target) is ignored — this function only reports + // neighbors. Callers with an exact match use loadSnapshotAt. + } + + return .{ .earlier = earlier, .later = later }; +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; @@ -548,3 +636,134 @@ test "loadHistoryDir: corrupt files are skipped, others still load" { // Only the good one lands. try testing.expectEqual(@as(usize, 1), result.snapshots.len); } + +// ── findNearestSnapshot / loadSnapshotAt tests ───────────────── + +test "findNearestSnapshot: empty dir returns both null" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 15)); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expectEqual(@as(?Date, null), result.later); +} + +test "findNearestSnapshot: non-existent dir returns both null" { + const result = try findNearestSnapshot( + "/tmp/zfin-history-nearest-never-exists-91823", + Date.fromYmd(2024, 3, 15), + ); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expectEqual(@as(?Date, null), result.later); +} + +test "findNearestSnapshot: earlier and later around gap" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-20-portfolio.srf", .data = "" }); + // Noise files that should be ignored. + try tmp.dir.writeFile(.{ .sub_path = "random.txt", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "rollup.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "bogus-date-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 14)); + try testing.expect(result.earlier != null); + try testing.expect(result.later != null); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days); +} + +test "findNearestSnapshot: before earliest — only later set" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(path, Date.fromYmd(2024, 1, 1)); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expect(result.later != null); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.later.?.days); +} + +test "findNearestSnapshot: after latest — only earlier set" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(path, Date.fromYmd(2025, 1, 1)); + try testing.expect(result.earlier != null); + try testing.expectEqual(@as(?Date, null), result.later); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 12).days), result.earlier.?.days); +} + +test "findNearestSnapshot: target hits a file exactly — returns neighbors, not self" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2024-03-10-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-12-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(path, Date.fromYmd(2024, 3, 12)); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 10).days), result.earlier.?.days); + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), result.later.?.days); +} + +test "loadSnapshotAt: missing file returns FileNotFound" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = loadSnapshotAt(testing.allocator, path, Date.fromYmd(2024, 3, 15)); + try testing.expectError(error.FileNotFound, result); +} + +test "loadSnapshotAt: happy path loads and parses" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Minimal valid snapshot + const snap_bytes = + \\#!srfv1 + \\#!created=1777589000 + \\kind::meta,snapshot_version:num:1,as_of_date::2024-03-15,captured_at:num:1777589000,zfin_version::test,stale_count:num:0 + \\kind::total,scope::liquid,value:num:1000 + \\kind::total,scope::net_worth,value:num:1000 + \\kind::total,scope::illiquid,value:num:0 + \\ + ; + try tmp.dir.writeFile(.{ .sub_path = "2024-03-15-portfolio.srf", .data = snap_bytes }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + var loaded = try loadSnapshotAt(testing.allocator, path, Date.fromYmd(2024, 3, 15)); + defer loaded.deinit(testing.allocator); + + try testing.expectEqual(@as(i32, Date.fromYmd(2024, 3, 15).days), loaded.snap.meta.as_of_date.days); + try testing.expectEqual(@as(usize, 3), loaded.snap.totals.len); +} diff --git a/src/tui.zig b/src/tui.zig index 8fd5409..b459c7c 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -17,6 +17,8 @@ const history_tab = @import("tui/history_tab.zig"); const projections_tab = @import("tui/projections_tab.zig"); const history_io = @import("history.zig"); const timeline = @import("analytics/timeline.zig"); +const compare_core = @import("compare.zig"); +const compare_view = @import("views/compare.zig"); /// Comptime-generated table of single-character grapheme slices with static lifetime. /// This avoids dangling pointers from stack-allocated temporaries in draw functions. @@ -121,6 +123,32 @@ pub const StyledLine = struct { cell_styles: ?[]const vaxis.Style = null, }; +/// 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 HistoryCompareResources = 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: *HistoryCompareResources, 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(); + } +}; + // ── Tab-specific types ─────────────────────────────────────────── // These logically belong to individual tab files, but live here because // App's struct fields reference them and Zig requires field types to be @@ -381,6 +409,29 @@ pub const App = struct { history_loaded: bool = false, history_disabled: bool = false, // true when no portfolio path (history requires it) history_timeline: ?history_io.LoadedTimeline = null, + // Cursor for the recent-snapshots table. 0 = newest row (live + // pseudo-row if available, otherwise newest snapshot). + history_cursor: usize = 0, + // Up to two rows marked for comparison via `compare_select` + // (default 's' / space). Entries are indices into the displayed + // table. `null` slots mean "no selection". Fixed-size array pins + // the cap at type level. + history_selections: [2]?usize = .{ null, null }, + // Active compare view. When non-null, the history tab renders + // compare output instead of the timeline. Cleared by + // `compare_cancel` (default Esc) or toggling compare_commit again. + history_compare_view: ?compare_view.CompareView = null, + // Resources backing `history_compare_view` — owned by the App so + // their lifetime matches the view's. Cleared together with the + // view. + history_compare_resources: ?HistoryCompareResources = null, + // First line-number where the recent-snapshots table body starts. + // Set during `history_tab.buildStyledLines`; consumed by the key + // handler's ensure-cursor-visible logic. + history_table_first_line: usize = 0, + // Number of rows currently rendered in the table (including the + // live pseudo-row when present). Used for cursor clamping. + history_table_row_count: usize = 0, // Projections tab state projections_loaded: bool = false, @@ -884,6 +935,23 @@ pub const App = struct { } fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + // Ctrl+L: full screen redraw (standard TUI convention, not configurable) + if (key.codepoint == 'l' and key.mods.ctrl) { + ctx.queueRefresh() catch {}; + return ctx.consumeAndRedraw(); + } + + // History-tab compare intercept. + // + // `s` / space / `c` / escape have existing global bindings + // (select_symbol, collapse_all_calls, plus the account-filter + // escape handler below) that would otherwise handle (or + // silently consume) these keys. This intercept runs first when + // the user is in the history tab so compare behavior wins. + if (self.active_tab == .history) { + if (history_tab.handleCompareKey(self, ctx, key)) return; + } + // Escape: clear account filter on portfolio tab, no-op otherwise if (key.codepoint == vaxis.Key.escape) { if (self.active_tab == .portfolio and self.account_filter != null) { @@ -897,12 +965,6 @@ pub const App = struct { return; } - // Ctrl+L: full screen redraw (standard TUI convention, not configurable) - if (key.codepoint == 'l' and key.mods.ctrl) { - ctx.queueRefresh() catch {}; - return ctx.consumeAndRedraw(); - } - const action = self.keymap.matchAction(key) orelse return; switch (action) { .quit => { @@ -1132,6 +1194,34 @@ pub const App = struct { return ctx.consumeAndRedraw(); } }, + // History-tab compare actions are normally intercepted in + // `handleCompareKey` before `matchAction` runs (because the + // default 's'/'c'/space/escape key bindings belong to other + // actions). These cases exist so the switch is exhaustive + // and so future user-supplied keybindings targeting these + // action names work correctly. + .compare_select => { + if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) { + history_tab.toggleSelectionAt(self, self.history_cursor); + return ctx.consumeAndRedraw(); + } + }, + .compare_commit => { + if (self.active_tab == .history) { + if (self.history_compare_view != null) { + history_tab.clearCompareState(self); + } else { + history_tab.commitCompareExternal(self); + } + return ctx.consumeAndRedraw(); + } + }, + .compare_cancel => { + if (self.active_tab == .history) { + history_tab.clearCompareState(self); + return ctx.consumeAndRedraw(); + } + }, } } @@ -1157,6 +1247,12 @@ pub const App = struct { stepCursor(&self.options_cursor, self.options_rows.items.len, n); self.ensureOptionsCursorVisible(); } + } else if (self.active_tab == .history and self.history_compare_view == null and self.history_table_row_count > 0) { + // Cursor navigation in the recent-snapshots table. Disabled + // during compare view mode (Esc/`c` returns first). + if (self.shouldDebounceWheel()) return; + stepCursor(&self.history_cursor, self.history_table_row_count, n); + self.ensureHistoryCursorVisible(); } else { if (n > 0) { self.scroll_offset += @intCast(n); @@ -1187,6 +1283,20 @@ pub const App = struct { } } + /// Scroll so that the history-tab cursor row is visible. Uses the + /// `history_table_first_line` metadata stashed during the most + /// recent render; safe when it's zero (initial state) because the + /// cursor is also zero then. + pub fn ensureHistoryCursorVisible(self: *App) void { + const cursor_line = self.history_table_first_line + self.history_cursor; + const vis: usize = self.visible_height; + if (cursor_line < self.scroll_offset) { + self.scroll_offset = cursor_line; + } else if (cursor_line >= self.scroll_offset + vis) { + self.scroll_offset = cursor_line - vis + 1; + } + } + fn toggleExpand(self: *App) void { if (self.portfolio_rows.items.len == 0) return; if (self.cursor >= self.portfolio_rows.items.len) return; @@ -2284,6 +2394,15 @@ pub fn run( ); app_inst.prefetched_prices = load_result.prices; } + + // Eagerly compute PortfolioData so the history-tab's live + // pseudo-row + compare-to-live-now works from first render, + // without requiring the user to visit the portfolio tab + // first. Cheap (pure compute + cache reads) once prices are + // already in hand. + if (app_inst.portfolio != null) { + portfolio_tab.loadPortfolioData(app_inst); + } } defer if (app_inst.portfolio) |*pf| pf.deinit(); diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index f3cd748..5f6f253 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -1,6 +1,6 @@ -//! TUI history tab — portfolio value timeline over time. +//! TUI history tab — portfolio value timeline + compare-mode overlay. //! -//! Layout (top-to-bottom): +//! 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 @@ -8,18 +8,31 @@ //! 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). No analytics live here: this module -//! is only responsible for driving data loading on tab activation and -//! converting the timeline series into `StyledLine`s for rendering. +//! `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`) -//! -//! Default metric is `.liquid` — matches the CLI history default and -//! is the headline view for day-to-day watching (illiquid barely -//! changes, net worth is dominated by liquid anyway). +//! - `s` / space / `c` / Esc — compare (intercepted in `tui.zig` +//! before matchAction, see `handleCompareKey` below) const std = @import("std"); const vaxis = @import("vaxis"); @@ -30,13 +43,11 @@ const tui = @import("../tui.zig"); const history_io = @import("../history.zig"); const timeline = @import("../analytics/timeline.zig"); const view = @import("../views/history.zig"); +const compare_core = @import("../compare.zig"); +const compare_view = @import("../views/compare.zig"); const App = tui.App; const StyledLine = tui.StyledLine; -// Show at most this many rows in the bottom table. Older rows still -// contribute to the chart and windows block, just not to the table. -const max_table_rows: usize = 30; - // ── Data loading ────────────────────────────────────────────── pub fn loadData(app: *App) void { @@ -65,13 +76,22 @@ pub fn freeLoaded(app: *App) void { 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. -/// -/// Starts at liquid to match the default and the "most-useful-first" -/// reading order: markets first, illiquid revaluations second, total -/// last. pub fn cycleMetric(app: *App) void { app.history_metric = switch (app.history_metric) { .liquid => .illiquid, @@ -81,10 +101,6 @@ pub fn cycleMetric(app: *App) void { } /// Cycle resolution: auto → daily → weekly → monthly → auto. -/// -/// Null = auto (defers to `timeline.selectResolution`). The cycle runs -/// through the explicit choices so the user can force a given resolution -/// when the auto pick doesn't match intent. pub fn cycleResolution(app: *App) void { app.history_resolution = switch (app.history_resolution orelse { app.history_resolution = .daily; @@ -96,15 +112,431 @@ pub fn cycleResolution(app: *App) void { }; } +// ── Compare selection model ────────────────────────────────── + +/// Returns the number of currently selected rows (0, 1, or 2). +fn selectionCount(app: *const App) usize { + var n: usize = 0; + for (app.history_selections) |s| { + if (s != null) n += 1; + } + return n; +} + +/// Returns true if row `idx` is currently selected. +fn isSelected(app: *const App, idx: usize) bool { + for (app.history_selections) |s| { + if (s) |v| if (v == idx) return true; + } + return false; +} + +/// Toggle selection of row `idx`. With two already selected and this +/// row isn't one of them, sets a status hint and leaves state alone. +fn toggleSelection(app: *App, idx: usize) void { + // Already selected? Remove. + for (&app.history_selections) |*slot| { + if (slot.*) |v| { + if (v == idx) { + slot.* = null; + setSelectionStatus(app); + return; + } + } + } + // Find an empty slot. + for (&app.history_selections) |*slot| { + if (slot.* == null) { + slot.* = idx; + setSelectionStatus(app); + return; + } + } + // Both full, and this row isn't one of them. + app.setStatus("Two rows already selected — 's' on a selected row to deselect, or 'c' to compare"); +} + +/// Clear all selections. +fn clearSelections(app: *App) void { + app.history_selections = .{ null, null }; +} + +fn setSelectionStatus(app: *App) void { + const n = selectionCount(app); + switch (n) { + 0 => app.setStatus(""), + 1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"), + 2 => app.setStatus("Selected 2 rows — press 'c' to compare"), + else => unreachable, + } +} + +/// Public entry for the `compare_select` action dispatched via +/// matchAction. Internally delegates to the same toggle function the +/// intercept uses. +pub fn toggleSelectionAt(app: *App, idx: usize) void { + toggleSelection(app, idx); +} + +/// Public entry for the `compare_commit` action. +pub fn commitCompareExternal(app: *App) void { + commitCompare(app); +} + +/// Public entry for the `compare_cancel` action or internal Esc path: +/// clear selections and any active compare view. +pub fn clearCompareState(app: *App) void { + clearCompareView(app); + clearSelections(app); + app.setStatus(""); +} + +// ── Compare commit ─────────────────────────────────────────── + +/// Attempt to run compare. Called when the user presses `c`. +/// No-ops (with status hint) if the selection set isn't exactly 2. +fn commitCompare(app: *App) void { + const sel_count = selectionCount(app); + if (sel_count < 2) { + if (sel_count == 0) { + app.setStatus("Select two rows with 's' (or space), then press 'c' to compare"); + } else { + app.setStatus("Select one more row with 's' (or space), then press 'c' to compare"); + } + return; + } + + // At this point both slots are filled. + const sel_a = app.history_selections[0].?; + const sel_b = app.history_selections[1].?; + if (sel_a == sel_b) { + // Shouldn't happen via toggle logic, but guard anyway. + app.setStatus("Selected rows are the same — clear one and reselect"); + return; + } + + buildCompareFromSelections(app, sel_a, sel_b) catch |err| { + var msg_buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed"; + app.setStatus(msg); + clearCompareView(app); + }; +} + +/// Build + stash the compare view from two selected row indices. +fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { + // Resolve each row-index into its date and source (live vs + // snapshot). This requires re-computing the table row list the + // way the renderer does — shared helper keeps the two paths in + // sync. + var arena_state = std.heap.ArenaAllocator.init(app.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = try collectTableRows(arena, app); + if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) { + app.setStatus("Stale selection — please re-select"); + clearSelections(app); + return; + } + const row_a = rows[sel_a]; + const row_b = rows[sel_b]; + + // Order: older → newer. + const older = if (row_a.date.days < row_b.date.days) row_a else row_b; + const newer = if (row_a.date.days < row_b.date.days) row_b else row_a; + + // Build up the resources + maps for each side. + var resources: tui.HistoryCompareResources = .{}; + errdefer resources.deinit(app.allocator); + + const portfolio_path = app.portfolio_path orelse { + app.setStatus("No portfolio loaded — can't build compare"); + return error.PortfolioLoadFailed; + }; + const hist_dir = try history_io.deriveHistoryDir(app.allocator, portfolio_path); + defer app.allocator.free(hist_dir); + + // SAFETY: assigned in both branches of the `if (older.is_live)` + // below before first read (via `then_map_ptr.*` when passed to + // buildCompareView). + var then_map_ptr: *compare_view.HoldingMap = undefined; + var then_liquid: f64 = 0; + // "Then" side. + if (older.is_live) { + var map: compare_view.HoldingMap = .init(app.allocator); + errdefer map.deinit(); + try aggregateFromSummary(app.portfolio_summary.?, &map); + resources.then_live_map = map; + then_map_ptr = &resources.then_live_map.?; + then_liquid = liveLiquid(app); + } else { + const side = try compare_core.loadSnapshotSide(app.allocator, hist_dir, older.date); + resources.then_snap = side; + then_map_ptr = &resources.then_snap.?.map; + then_liquid = side.liquid; + } + + // SAFETY: assigned in both branches of the `if (newer.is_live)` + // below before first read. + var now_map_ptr: *compare_view.HoldingMap = undefined; + var now_liquid: f64 = 0; + if (newer.is_live) { + var map: compare_view.HoldingMap = .init(app.allocator); + errdefer map.deinit(); + try aggregateFromSummary(app.portfolio_summary.?, &map); + resources.now_live_map = map; + now_map_ptr = &resources.now_live_map.?; + now_liquid = liveLiquid(app); + } else { + const side = try compare_core.loadSnapshotSide(app.allocator, hist_dir, newer.date); + resources.now_snap = side; + now_map_ptr = &resources.now_snap.?.map; + now_liquid = side.liquid; + } + + // "now is live" only when the NEWER endpoint is the live row. + const now_is_live = newer.is_live; + + const cv = try compare_view.buildCompareView( + app.allocator, + older.date, + newer.date, + now_is_live, + then_liquid, + now_liquid, + then_map_ptr, + now_map_ptr, + ); + // No error paths between here and install, so no errdefer needed. + // If buildCompareView returned Ok, we own cv's backing memory + // (allocated inside buildCompareView) and pass ownership to the App. + + // Commit: install both onto the App. From this point onwards the + // App owns them and clearCompareView handles teardown. + clearCompareView(app); + app.history_compare_view = cv; + app.history_compare_resources = resources; + app.setStatus("Comparing — Esc or 'c' to return to timeline"); +} + +fn liveLiquid(app: *const App) f64 { + if (app.portfolio_summary) |s| return s.total_value; + return 0; +} + +/// Aggregate the live portfolio's per-symbol holdings from the +/// already-computed `PortfolioSummary.allocations`. +/// +/// We derive the post-`price_ratio` per-share price from +/// `market_value / shares` rather than reading raw prices + applying +/// ratio ourselves. This matches the snapshot aggregation (which +/// stores post-ratio prices in its lot rows) and keeps the two +/// endpoints apples-to-apples. Edge case: covered-call adjustments +/// (`adjustForCoveredCalls`) skew the derived per-share price +/// slightly for symbols with ITM sold calls; matches the CLI behavior +/// and is acceptable. +fn aggregateFromSummary( + summary: zfin.valuation.PortfolioSummary, + out: *compare_view.HoldingMap, +) !void { + for (summary.allocations) |a| { + if (a.shares == 0) continue; + const price = a.market_value / a.shares; + try out.put(a.symbol, .{ .shares = a.shares, .price = price }); + } +} + +// ── Compare key handler (intercepted from tui.zig) ─────────── + +/// Intercepted before `matchAction` runs when the active tab is +/// history. Returns true if the key was consumed. +pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) bool { + // Escape: exit compare view, or clear selections. + if (key.codepoint == vaxis.Key.escape) { + if (app.history_compare_view != null) { + clearCompareView(app); + clearSelections(app); + app.setStatus(""); + ctx.consumeAndRedraw(); + return true; + } + if (selectionCount(app) > 0) { + clearSelections(app); + app.setStatus(""); + ctx.consumeAndRedraw(); + return true; + } + return false; + } + + // 's' or space: toggle selection on the cursor row. + if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) { + // Disabled while the compare view is up — Esc to return first. + if (app.history_compare_view != null) return false; + if (app.history_table_row_count == 0) return false; + toggleSelection(app, app.history_cursor); + ctx.consumeAndRedraw(); + return true; + } + + // 'c': commit compare, or exit compare view if already active. + if (key.matches('c', .{})) { + if (app.history_compare_view != null) { + clearCompareView(app); + clearSelections(app); + app.setStatus(""); + ctx.consumeAndRedraw(); + return true; + } + commitCompare(app); + ctx.consumeAndRedraw(); + return true; + } + + return false; +} + +// ── Table row model ────────────────────────────────────────── + +/// One row in the rendered recent-snapshots table. +/// +/// `is_live` is true for the synthesized "today (live)" pseudo-row +/// at index 0 when the TUI has portfolio-summary state loaded. All +/// other rows correspond to real snapshots, one per `RowDelta`. +pub const TableRow = struct { + date: zfin.Date, + is_live: bool, + liquid: f64, + illiquid: f64, + net_worth: f64, + d_liquid: ?f64, + d_illiquid: ?f64, + d_net_worth: ?f64, +}; + +/// Build the list of table rows in display order (newest-first). +/// +/// Row 0 is the live pseudo-row if available, followed by all snapshot +/// rows from newest to oldest. The `RowDelta` slice from +/// `computeRowDeltas` is oldest-first; we reverse it and optionally +/// prepend the live row. +pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow { + const timeline_opt = app.history_timeline; + if (timeline_opt == null) return &.{}; + const series = timeline_opt.?.series; + if (series.points.len == 0) return &.{}; + + const resolution = app.history_resolution orelse timeline.selectResolution(series.points); + const aggregated = try timeline.aggregatePoints(arena, series.points, resolution); + const deltas = try timeline.computeRowDeltas(arena, aggregated); + + // Decide whether to prepend a live pseudo-row. Requires both a + // portfolio (for illiquid) and a summary (for liquid total). + const live_opt = buildLiveRow(app, deltas); + + var list: std.ArrayList(TableRow) = .empty; + try list.ensureTotalCapacity(arena, deltas.len + (if (live_opt == null) @as(usize, 0) else @as(usize, 1))); + + if (live_opt) |live| try list.append(arena, live); + + // deltas is oldest-first; emit newest-first. + var i: usize = deltas.len; + while (i > 0) { + i -= 1; + const d = deltas[i]; + try list.append(arena, .{ + .date = d.date, + .is_live = false, + .liquid = d.liquid, + .illiquid = d.illiquid, + .net_worth = d.net_worth, + .d_liquid = d.d_liquid, + .d_illiquid = d.d_illiquid, + .d_net_worth = d.d_net_worth, + }); + } + + return list.toOwnedSlice(arena); +} + +/// Build the synthetic "today (live)" row from App state, or return +/// null if the required state isn't populated. +/// +/// Deltas are computed against the newest snapshot in `deltas` (which +/// is index `deltas.len - 1` — deltas is oldest-first). +fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow { + if (app.portfolio == null) return null; + const summary = app.portfolio_summary orelse return null; + + const liquid = summary.total_value; + const illiquid = app.portfolio.?.totalIlliquid(); + const net_worth = liquid + illiquid; + + // Deltas vs. the most recent snapshot. + var d_liquid: ?f64 = null; + var d_illiquid: ?f64 = null; + var d_net_worth: ?f64 = null; + if (deltas.len > 0) { + const newest = deltas[deltas.len - 1]; + d_liquid = liquid - newest.liquid; + d_illiquid = illiquid - newest.illiquid; + d_net_worth = net_worth - newest.net_worth; + } + + return .{ + .date = fmt.todayDate(), + .is_live = true, + .liquid = liquid, + .illiquid = illiquid, + .net_worth = net_worth, + .d_liquid = d_liquid, + .d_illiquid = d_illiquid, + .d_net_worth = d_net_worth, + }; +} + // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { - const series: ?timeline.TimelineSeries = if (app.history_timeline) |tl| tl.series else null; - return renderHistoryLines(arena, app.theme, series, app.history_metric, app.history_resolution); + // 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; } -/// Pure renderer — no App dependency. Builds the styled lines from a -/// timeline series, a focused metric, and an optional resolution override. +/// 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, @@ -112,6 +544,75 @@ pub fn renderHistoryLines( 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() }); @@ -121,13 +622,13 @@ pub fn renderHistoryLines( const series = series_opt orelse { try lines.append(arena, .{ .text = " No history snapshots yet.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " Run: zfin snapshot (or wait for the daily cron)", .style = th.mutedStyle() }); - return lines.toOwnedSlice(arena); + 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.toOwnedSlice(arena); + return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 }; } const metric_label = focus_metric.label(); @@ -165,41 +666,54 @@ pub fn renderHistoryLines( // ── Recent snapshots table ─────────────────────────────────── const resolution = resolution_override orelse timeline.selectResolution(points); - const aggregated = try timeline.aggregatePoints(arena, points, resolution); - const deltas = try timeline.computeRowDeltas(arena, aggregated); var rlabel_buf: [32]u8 = undefined; const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution); const table_header = try std.fmt.allocPrint( arena, - " Recent snapshots {s}", + " Recent snapshots {s} (j/k: move, s/space: select, c: compare)", .{rlabel}, ); try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - // Header widths mirror the CLI exactly. Leading " " indent + 10-char - // date + 2 gap + three 28-char composite cells separated by 2 gaps. + // Column header: extra 2-char left margin for the selection marker. const header_line = try std.fmt.allocPrint( arena, - " {s:>10} {s:>28} {s:>28} {s:>28}", + " {s:>10} {s:>28} {s:>28} {s:>28}", .{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" }, ); try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() }); - // Render up to max_table_rows newest-first. - const start = if (deltas.len > max_table_rows) deltas.len - max_table_rows else 0; - const window = deltas[start..]; - var i: usize = window.len; - while (i > 0) { - i -= 1; - const row = window[i]; - const text = try fmtTableRow(arena, row); - const style = rowStyle(th, row, focus_metric); + 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.toOwnedSlice(arena); + 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 @@ -213,18 +727,13 @@ fn appendWindowsBlock( metric: timeline.Metric, metric_label: []const u8, ) !void { - _ = metric_label; // outer "Portfolio History" + chart header already name the metric + _ = metric_label; const today = points[points.len - 1].as_of_date; const ws = try timeline.computeWindowSet(arena, points, metric, today); - // Arena-backed: no deinit needed. - // Block title — just "Change". Metric is redundant with the chart - // header below ("Chart: Liquid") and the outer "Portfolio History". try lines.append(arena, .{ .text = " Change", .style = th.headerStyle() }); - // Column header + dashed divider. Widths pinned to view constants - // (12 / 18 / 10). const header_line = try std.fmt.allocPrint( arena, " {s:<12} {s:>18} {s:>10}", @@ -251,27 +760,33 @@ fn appendWindowsBlock( } } -/// Build a recent-snapshots table row. Cells align with the header -/// because both use `view.fmtValueDeltaCell` with `view.table_cell_width`. -fn fmtTableRow(arena: std.mem.Allocator, row: timeline.RowDelta) ![]const u8 { +/// Build a recent-snapshots table row. `selected` causes a `*` marker +/// to appear in the two-column left margin instead of spaces. +fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 { var date_buf: [10]u8 = undefined; var liq_cell_buf: [64]u8 = undefined; var ill_cell_buf: [64]u8 = undefined; var nw_cell_buf: [64]u8 = undefined; - const date_s = row.date.format(&date_buf); + // Live row: replace the date column with "today (live)" right-aligned to 10. + const date_s: []const u8 = if (row.is_live) + "today" + else + row.date.format(&date_buf); + const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width); const ill_cell = view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width); const nw_cell = view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width); + const marker: []const u8 = if (selected) "* " else " "; return std.fmt.allocPrint( arena, - " {s:>10} {s} {s} {s}", - .{ date_s, liq_cell, ill_cell, nw_cell }, + " {s}{s:>10} {s} {s} {s}", + .{ marker, date_s, liq_cell, ill_cell, nw_cell }, ); } -fn rowStyle(th: theme.Theme, row: timeline.RowDelta, metric: timeline.Metric) vaxis.Cell.Style { +fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style { const d_opt: ?f64 = switch (metric) { .liquid => row.d_liquid, .illiquid => row.d_illiquid, @@ -292,6 +807,113 @@ fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 { }; } +// ── Compare-mode rendering ──────────────────────────────────── +// +// Thin adapter: pulls pre-formatted cells from `views/compare.zig` +// and drops them into vaxis-styled lines. Layout widths, number +// formatting, and label pluralization all come from the view layer — +// this function owns only the TUI-specific style mapping (via +// `theme.styleFor`) and the " " leading indent that matches the +// rest of the history tab. The CLI renderer in `commands/compare.zig` +// uses the same shared cells + format constants, so the two outputs +// stay in lockstep. + +/// Render a `CompareView` as a sequence of styled lines for the TUI. +/// Allocates all line text into `arena`; strings borrow from it. +pub fn renderCompareLines( + arena: std.mem.Allocator, + th: theme.Theme, + cv: compare_view.CompareView, +) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + + // Spacer matching the rest of the tab for layout consistency + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // ── Header ── + var then_buf: [10]u8 = undefined; + var now_buf: [10]u8 = undefined; + const then_str = cv.then_date.format(&then_buf); + const now_str = compare_view.nowLabel(cv, &now_buf); + + const header = try std.fmt.allocPrint( + arena, + " Portfolio comparison: {s} → {s} ({d} day{s})", + .{ then_str, now_str, cv.days_between, compare_view.dayPlural(cv.days_between) }, + ); + try lines.append(arena, .{ .text = header, .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // ── Totals line ── + { + var t_then: [24]u8 = undefined; + var t_now: [24]u8 = undefined; + var t_delta: [32]u8 = undefined; + var t_pct: [16]u8 = undefined; + const t = compare_view.buildTotalsCells(cv.liquid, &t_then, &t_now, &t_delta, &t_pct); + + const totals_text = try std.fmt.allocPrint( + arena, + " Liquid: {s}{s}{s} {s} {s}", + .{ t.then, compare_view.arrow, t.now, t.delta, t.pct }, + ); + try lines.append(arena, .{ .text = totals_text, .style = th.styleFor(t.style) }); + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // ── Per-symbol table ── + if (cv.held_count == 0) { + try lines.append(arena, .{ + .text = " No symbols held throughout this period.", + .style = th.mutedStyle(), + }); + } else { + const subtitle = try std.fmt.allocPrint( + arena, + " Per-symbol price change ({d} held throughout)", + .{cv.held_count}, + ); + try lines.append(arena, .{ .text = subtitle, .style = th.mutedStyle() }); + + for (cv.symbols) |s| { + var p_then: [24]u8 = undefined; + var p_now: [24]u8 = undefined; + var p_pct: [16]u8 = undefined; + var p_dollar: [32]u8 = undefined; + const c = compare_view.buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar); + + // Single-color per row using the shared row template. + const row_text = try std.fmt.allocPrint( + arena, + " " ++ compare_view.symbol_row_fmt, + .{ c.symbol, c.price_then, compare_view.arrow, c.price_now, c.pct, c.dollar }, + ); + try lines.append(arena, .{ .text = row_text, .style = th.styleFor(c.style) }); + } + } + + // ── Hidden count ── + if (cv.added_count > 0 or cv.removed_count > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + const hidden_text = try std.fmt.allocPrint( + arena, + " ({d} added, {d} removed since {s} — hidden)", + .{ cv.added_count, cv.removed_count, then_str }, + ); + try lines.append(arena, .{ .text = hidden_text, .style = th.mutedStyle() }); + } + + // ── Footer hint ── + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " Esc or 'c' to return to timeline", + .style = th.mutedStyle(), + }); + + return lines.toOwnedSlice(arena); +} + // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; @@ -320,7 +942,7 @@ test "renderHistoryLines: renders windows + chart + table in correct order" { const pts = try a.alloc(timeline.TimelinePoint, 2); pts[0] = .{ - .as_of_date = Date.fromYmd(2026, 4, 20), + .as_of_date = Date.fromYmd(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, @@ -328,7 +950,7 @@ test "renderHistoryLines: renders windows + chart + table in correct order" { .tax_types = &.{}, }; pts[1] = .{ - .as_of_date = Date.fromYmd(2026, 4, 21), + .as_of_date = Date.fromYmd(2024, 3, 15), .net_worth = 1100, .liquid = 800, .illiquid = 300, @@ -339,7 +961,6 @@ test "renderHistoryLines: renders windows + chart + table in correct order" { const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a }; const lines = try renderHistoryLines(a, th, series, .liquid, .daily); - // Find the indices of the three major section headers. var windows_idx: ?usize = null; var chart_idx: ?usize = null; var table_idx: ?usize = null; @@ -351,7 +972,6 @@ test "renderHistoryLines: renders windows + chart + table in correct order" { try testing.expect(windows_idx != null); try testing.expect(chart_idx != null); try testing.expect(table_idx != null); - // Order: windows → chart → table try testing.expect(windows_idx.? < chart_idx.?); try testing.expect(chart_idx.? < table_idx.?); } @@ -364,7 +984,7 @@ test "renderHistoryLines: windows block includes 1 day + All-time" { const pts = try a.alloc(timeline.TimelinePoint, 2); pts[0] = .{ - .as_of_date = Date.fromYmd(2026, 4, 20), + .as_of_date = Date.fromYmd(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, @@ -372,7 +992,7 @@ test "renderHistoryLines: windows block includes 1 day + All-time" { .tax_types = &.{}, }; pts[1] = .{ - .as_of_date = Date.fromYmd(2026, 4, 21), + .as_of_date = Date.fromYmd(2024, 3, 15), .net_worth = 1100, .liquid = 800, .illiquid = 300, @@ -401,7 +1021,7 @@ test "renderHistoryLines: table rows emitted newest-first and column order is Li const pts = try a.alloc(timeline.TimelinePoint, 3); pts[0] = .{ - .as_of_date = Date.fromYmd(2026, 4, 19), + .as_of_date = Date.fromYmd(2024, 3, 13), .net_worth = 900, .liquid = 600, .illiquid = 300, @@ -409,7 +1029,7 @@ test "renderHistoryLines: table rows emitted newest-first and column order is Li .tax_types = &.{}, }; pts[1] = .{ - .as_of_date = Date.fromYmd(2026, 4, 20), + .as_of_date = Date.fromYmd(2024, 3, 14), .net_worth = 1000, .liquid = 700, .illiquid = 300, @@ -417,7 +1037,7 @@ test "renderHistoryLines: table rows emitted newest-first and column order is Li .tax_types = &.{}, }; pts[2] = .{ - .as_of_date = Date.fromYmd(2026, 4, 21), + .as_of_date = Date.fromYmd(2024, 3, 15), .net_worth = 1100, .liquid = 800, .illiquid = 300, @@ -428,7 +1048,6 @@ test "renderHistoryLines: table rows emitted newest-first and column order is Li const lines = try renderHistoryLines(a, th, series, .liquid, .daily); - // Join all lines to scan row ordering. var joined: std.ArrayList(u8) = .empty; for (lines) |l| { try joined.appendSlice(a, l.text); @@ -436,16 +1055,14 @@ test "renderHistoryLines: table rows emitted newest-first and column order is Li } const text = joined.items; - // Header column order: Liquid before Illiquid before Net Worth const h_liq = std.mem.indexOf(u8, text, "Liquid") orelse return error.TestExpectedMatch; const h_ill = std.mem.indexOf(u8, text, "Illiquid") orelse return error.TestExpectedMatch; const h_nw = std.mem.indexOf(u8, text, "Net Worth") orelse return error.TestExpectedMatch; try testing.expect(h_liq < h_ill); try testing.expect(h_ill < h_nw); - // Newest-first: 2026-04-21 appears before 2026-04-19 in the text - const d_new = std.mem.lastIndexOf(u8, text, "2026-04-21") orelse return error.TestExpectedMatch; - const d_old = std.mem.lastIndexOf(u8, text, "2026-04-19") orelse return error.TestExpectedMatch; + 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); } @@ -457,7 +1074,7 @@ test "renderHistoryLines: metric cycling changes chart label and windows header" const pts = try a.alloc(timeline.TimelinePoint, 1); pts[0] = .{ - .as_of_date = Date.fromYmd(2026, 4, 20), + .as_of_date = Date.fromYmd(2024, 3, 15), .net_worth = 100, .liquid = 60, .illiquid = 40, @@ -496,6 +1113,174 @@ test "cycleMetric: liquid → illiquid → 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; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 592ae0f..5913fb9 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -43,6 +43,15 @@ pub const Action = enum { chart_timeframe_prev, history_metric_next, history_resolution_next, + /// History tab: toggle inclusion of the row under the cursor in the + /// compare-selection set (0, 1, or 2 rows). Defaults: 's' and space. + compare_select, + /// History tab: run compare if exactly 2 rows are selected; otherwise + /// status-bar hint. Default: 'c'. + compare_commit, + /// History tab: clear the current compare view (return to timeline) + /// or clear pending selections. Default: escape. + compare_cancel, sort_col_next, sort_col_prev, sort_reverse, @@ -114,6 +123,7 @@ const default_bindings = [_]Binding{ .{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } }, .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, + .{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, @@ -132,6 +142,15 @@ const default_bindings = [_]Binding{ .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, .{ .action = .history_metric_next, .key = .{ .codepoint = 'm' } }, .{ .action = .history_resolution_next, .key = .{ .codepoint = 't' } }, + // Compare bindings live AFTER the existing `s`/`c`/space bindings + // so `matchAction` (first-match-wins) returns the original action + // in non-history tabs. The history-tab intercept in `tui.zig` + // routes these keys directly to `handleCompareKey` before + // `matchAction` is consulted. + .{ .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 = .sort_col_next, .key = .{ .codepoint = '>' } }, .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, diff --git a/src/views/compare.zig b/src/views/compare.zig index 298f59a..3c33db3 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -1,9 +1,12 @@ //! `src/views/compare.zig` — view model for the portfolio comparison UX. //! -//! Used by the CLI `compare` command and (eventually) a TUI compare-mode -//! overlay on the history tab. Sits alongside `views/portfolio_sections.zig` -//! and `views/history.zig` in the views layer — renderer-agnostic display -//! data, no ANSI, no writer, no vaxis. +//! Renderer-agnostic display data: no ANSI, no writer, no vaxis. Sits +//! alongside `views/portfolio_sections.zig` and `views/history.zig` +//! in the views layer. CLI and TUI renderers both consume the +//! `CompareView` produced here: +//! +//! - CLI renderer: `src/commands/compare.zig` (ANSI writer) +//! - TUI renderer: `src/tui/history_tab.zig` (vaxis-styled lines) //! //! ## Semantics //! @@ -38,14 +41,16 @@ //! //! ## Contract //! -//! No IO, no fetching, no rendering. The CLI command is responsible for -//! loading snapshots, aggregating lot rows by symbol, and producing the -//! two `HoldingMap`s. This module does the math and returns a sorted, -//! styled view. +//! No IO, no fetching, no rendering. Callers are responsible for +//! loading snapshots, aggregating lot rows by symbol, and producing +//! the two `HoldingMap`s (see `src/compare.zig`). This module does +//! the math and returns a sorted, styled (via `StyleIntent`) view +//! that either renderer can consume. const std = @import("std"); const fmt = @import("../format.zig"); const Date = @import("../models/date.zig").Date; +const view_hist = @import("history.zig"); pub const StyleIntent = fmt.StyleIntent; @@ -458,3 +463,196 @@ test "buildCompareView: sort strictly descending across many symbols" { try testing.expectEqualStrings("D", view.symbols[3].symbol); try testing.expectEqualStrings("B", view.symbols[4].symbol); } + +// ── Layout constants + row-cell builders ───────────────────── +// +// Shared between the CLI and TUI renderers. Before this section +// existed, both renderers duplicated the column widths, format +// strings, and money/percent formatting — which is exactly the drift +// hazard `views/history.zig` was built to prevent. Every width or +// label change now lives here. + +/// Symbol column width — fits "BRK-B" + a note-derived CUSIP label +/// like "TGT2035" with slack. +pub const symbol_w: usize = 8; +/// Per-price column width. Fits "$999,999.99". +pub const price_w: usize = 10; +/// Percent column width. Fits "+999.99%". +pub const pct_w: usize = 8; +/// Signed-dollar column width. Fits "+$99,999,999.99" with slack. +pub const dollar_w: usize = 14; +/// Transition glyph between the `then` and `now` cells. +pub const arrow: []const u8 = " → "; + +// Comptime-built format specifiers so callers don't hardcode widths +// that might drift from the constants above. `cp` stringifies the +// width into a real Zig format spec; both CLI and TUI renderers +// compose these into their own line templates. +const cp = std.fmt.comptimePrint; +pub const symbol_fmt = cp("{{s:<{d}}}", .{symbol_w}); +pub const price_left_fmt = cp("{{s:<{d}}}", .{price_w}); +pub const price_right_fmt = cp("{{s:>{d}}}", .{price_w}); +pub const pct_fmt = cp("{{s:>{d}}}", .{pct_w}); +pub const dollar_fmt = cp("{{s:>{d}}}", .{dollar_w}); + +/// Single-color per-symbol row template. Ordered fields: +/// { symbol, price_then, arrow, price_now, pct, dollar } +/// Used by the TUI renderer (one style per line); the CLI composes +/// the row from smaller pieces to get per-segment coloring. +pub const symbol_row_fmt = symbol_fmt ++ " " ++ price_right_fmt ++ + "{s}" ++ price_left_fmt ++ " " ++ pct_fmt ++ " " ++ dollar_fmt; + +/// Pre-formatted cells for a single per-symbol row. Strings borrow +/// from the caller-owned buffers passed into `buildSymbolRowCells`. +/// +/// `style` is a semantic intent; renderers map to their own style +/// system (CLI: ANSI via `cli.setStyleIntent`, TUI: vaxis via +/// `theme.styleFor`). +pub const SymbolRowCells = struct { + symbol: []const u8, + price_then: []const u8, + price_now: []const u8, + pct: []const u8, + dollar: []const u8, + style: StyleIntent, +}; + +/// Build a single SymbolChange into display-ready cells. The four +/// caller-owned buffers back the returned strings and must outlive +/// the result. +pub fn buildSymbolRowCells( + s: SymbolChange, + price_then_buf: *[24]u8, + price_now_buf: *[24]u8, + pct_buf: *[16]u8, + dollar_buf: *[32]u8, +) SymbolRowCells { + return .{ + .symbol = s.symbol, + .price_then = fmt.fmtMoneyAbs(price_then_buf, s.price_then), + .price_now = fmt.fmtMoneyAbs(price_now_buf, s.price_now), + .pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change), + .dollar = view_hist.fmtSignedMoneyBuf(dollar_buf, s.dollar_change), + .style = s.style, + }; +} + +/// Pre-formatted cells for the liquid totals line (then → now, delta, +/// pct). Strings borrow from caller-owned buffers. +pub const TotalsCells = struct { + then: []const u8, + now: []const u8, + delta: []const u8, + pct: []const u8, + /// Style for the delta/pct portion; the then/now portion is + /// typically rendered in a muted/secondary style so the delta + /// stands out. CLI honors this split; the TUI applies `style` to + /// the whole line for simplicity. + style: StyleIntent, +}; + +pub fn buildTotalsCells( + t: TotalsRow, + then_buf: *[24]u8, + now_buf: *[24]u8, + delta_buf: *[32]u8, + pct_buf: *[16]u8, +) TotalsCells { + return .{ + .then = fmt.fmtMoneyAbs(then_buf, t.then), + .now = fmt.fmtMoneyAbs(now_buf, t.now), + .delta = view_hist.fmtSignedMoneyBuf(delta_buf, t.delta), + .pct = view_hist.fmtSignedPercentBuf(pct_buf, t.pct), + .style = t.style, + }; +} + +/// Format the "now" side label for the header. Snapshot-now shows +/// the date; live-now shows the literal "today". `buf` backs the +/// date case; caller must keep it alive. +pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 { + if (cv.now_is_live) return "today"; + return cv.now_date.format(buf); +} + +/// English pluralization for the "(N day[s])" header suffix. +pub fn dayPlural(n: i32) []const u8 { + return if (n == 1) "" else "s"; +} + +test "buildSymbolRowCells: wires through the right formatters" { + 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 s = SymbolChange{ + .symbol = "FOO", + .price_then = 100.00, + .price_now = 110.00, + .shares_held_throughout = 10, + .pct_change = 0.10, + .dollar_change = 100.0, + .style = .positive, + }; + const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar); + + try testing.expectEqualStrings("FOO", cells.symbol); + try testing.expectEqualStrings("$100.00", cells.price_then); + try testing.expectEqualStrings("$110.00", cells.price_now); + try testing.expectEqualStrings("+10.00%", cells.pct); + try testing.expectEqualStrings("+$100.00", cells.dollar); + try testing.expectEqual(StyleIntent.positive, cells.style); +} + +test "buildTotalsCells: wires through the right formatters" { + var b_then: [24]u8 = undefined; + var b_now: [24]u8 = undefined; + var b_delta: [32]u8 = undefined; + var b_pct: [16]u8 = undefined; + + const t = buildTotalsRow(10_000, 10_500); + const cells = buildTotalsCells(t, &b_then, &b_now, &b_delta, &b_pct); + + try testing.expectEqualStrings("$10,000.00", cells.then); + try testing.expectEqualStrings("$10,500.00", cells.now); + try testing.expectEqualStrings("+$500.00", cells.delta); + try testing.expectEqualStrings("+5.00%", cells.pct); + try testing.expectEqual(StyleIntent.positive, cells.style); +} + +test "nowLabel: live shows 'today', snapshot shows date" { + const cv_live = CompareView{ + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, + .now_is_live = true, + .liquid = buildTotalsRow(100, 100), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + }; + var buf: [10]u8 = undefined; + try testing.expectEqualStrings("today", nowLabel(cv_live, &buf)); + + const cv_snap = CompareView{ + .then_date = Date.fromYmd(2024, 1, 15), + .now_date = Date.fromYmd(2024, 3, 15), + .days_between = 60, + .now_is_live = false, + .liquid = buildTotalsRow(100, 100), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + }; + try testing.expectEqualStrings("2024-03-15", nowLabel(cv_snap, &buf)); +} + +test "dayPlural: 1 day singular, everything else plural" { + try testing.expectEqualStrings("", dayPlural(1)); + try testing.expectEqualStrings("s", dayPlural(0)); + try testing.expectEqualStrings("s", dayPlural(2)); + try testing.expectEqualStrings("s", dayPlural(60)); +}