tui implementation of compare mode
This commit is contained in:
parent
90ed1dabd3
commit
0aabdfb4f1
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
301
src/compare.zig
Normal 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);
|
||||
}
|
||||
219
src/history.zig
219
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/<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);
|
||||
}
|
||||
|
|
|
|||
131
src/tui.zig
131
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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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' } },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue