tui implementation of compare mode

This commit is contained in:
Emil Lerch 2026-05-01 10:27:25 -07:00
parent 90ed1dabd3
commit 0aabdfb4f1
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 2143 additions and 1092 deletions

File diff suppressed because it is too large Load diff

301
src/compare.zig Normal file
View file

@ -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);
}

View file

@ -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/<date>-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);
}

View file

@ -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();

File diff suppressed because it is too large Load diff

View file

@ -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' } },

View file

@ -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));
}