1770 lines
65 KiB
Zig
1770 lines
65 KiB
Zig
//! TUI history tab — portfolio value timeline + compare-mode overlay.
|
|
//!
|
|
//! Timeline layout (top-to-bottom):
|
|
//! 1. Rolling-windows block for the focused metric
|
|
//! (1D / 1W / 1M / YTD / 1Y / 3Y / 5Y / 10Y / All-time)
|
|
//! 2. Braille timeline chart for the focused metric
|
|
//! 3. "Recent snapshots" table: Liquid | Illiquid | Net Worth with
|
|
//! per-row Δ vs. previous row. Newest-first. Row colored by the
|
|
//! focused-metric delta.
|
|
//!
|
|
//! Compare mode overrides the entire output with a `CompareView`
|
|
//! render when two rows have been selected (via `s` / space) and
|
|
//! confirmed via `c`. Esc or another `c` returns to timeline.
|
|
//!
|
|
//! Selection UX:
|
|
//! - `s` / space — toggle selection of the row under the cursor.
|
|
//! Up to two rows can be selected; a third attempt is rejected
|
|
//! with a status hint.
|
|
//! - `c` — run compare if exactly two rows are selected; otherwise
|
|
//! status hint.
|
|
//! - Esc — exit compare view if active, else clear pending selections.
|
|
//!
|
|
//! The "today (live)" pseudo-row is conditional: it appears as the
|
|
//! newest row when `app.portfolio_summary` and `app.prefetched_prices`
|
|
//! are populated. When present, `history_cursor = 0` points at it.
|
|
//!
|
|
//! Consumes `src/analytics/timeline.zig` (pure compute) and
|
|
//! `src/history.zig` (snapshot IO). Compare composition is delegated
|
|
//! to `src/compare.zig`; compare rendering to `src/views/compare.zig`.
|
|
//!
|
|
//! Keybinds:
|
|
//! - `m` cycles chart metric (`history_metric_next`)
|
|
//! - `t` cycles resolution (`history_resolution_next`)
|
|
//! - `s` / space / `c` / Esc — compare (intercepted in `tui.zig`
|
|
//! before matchAction, see `handleCompareKey` below)
|
|
|
|
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const theme = @import("theme.zig");
|
|
const tui = @import("../tui.zig");
|
|
const history = @import("../history.zig");
|
|
const timeline = @import("../analytics/timeline.zig");
|
|
const view = @import("../views/history.zig");
|
|
const compare_core = @import("../compare.zig");
|
|
const compare_view = @import("../views/compare.zig");
|
|
const App = tui.App;
|
|
const StyledLine = tui.StyledLine;
|
|
|
|
/// Composite key for `App.history_expanded_buckets`. Keying by
|
|
/// `bucket_start.days` alone collides on edge-aligned parents
|
|
/// and children — e.g. yearly 2024 starts on 2024-01-01, and so
|
|
/// does its child quarterly Q1 2024. Tagging by tier
|
|
/// disambiguates so expanding the parent doesn't auto-expand
|
|
/// the child.
|
|
pub const BucketKey = struct {
|
|
tier: timeline.Tier,
|
|
days: i32,
|
|
};
|
|
|
|
fn keyFor(b: timeline.TierBucket) BucketKey {
|
|
return .{ .tier = b.tier, .days = b.bucket_start.days };
|
|
}
|
|
|
|
fn keyForRow(row: TableRow) ?BucketKey {
|
|
const t = row.tier orelse return null;
|
|
const start = row.bucket_start orelse return null;
|
|
return .{ .tier = t, .days = start.days };
|
|
}
|
|
|
|
// ── Data loading ──────────────────────────────────────────────
|
|
|
|
pub fn loadData(app: *App) void {
|
|
app.history_loaded = true;
|
|
freeLoaded(app);
|
|
|
|
const portfolio_path = app.portfolio_path orelse {
|
|
app.setStatus("History tab requires a loaded portfolio");
|
|
return;
|
|
};
|
|
|
|
app.history_timeline = history.loadTimeline(app.io, app.allocator, portfolio_path) catch {
|
|
app.setStatus("Failed to read history/ directory");
|
|
return;
|
|
};
|
|
|
|
if (app.history_timeline.?.loaded.snapshots.len == 0) {
|
|
freeLoaded(app);
|
|
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
|
|
}
|
|
}
|
|
|
|
/// Release the loaded timeline (if any).
|
|
pub fn freeLoaded(app: *App) void {
|
|
if (app.history_timeline) |*tl| {
|
|
tl.deinit();
|
|
app.history_timeline = null;
|
|
}
|
|
clearCompareView(app);
|
|
}
|
|
|
|
/// Clear the compare-view state (selections are preserved).
|
|
fn clearCompareView(app: *App) void {
|
|
if (app.history_compare_view) |*cv| {
|
|
cv.deinit(app.allocator);
|
|
app.history_compare_view = null;
|
|
}
|
|
if (app.history_compare_resources) |*res| {
|
|
res.deinit(app.allocator);
|
|
app.history_compare_resources = null;
|
|
}
|
|
}
|
|
|
|
/// Cycle the displayed metric: liquid → illiquid → net_worth → liquid.
|
|
pub fn cycleMetric(app: *App) void {
|
|
app.history_metric = switch (app.history_metric) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
}
|
|
|
|
/// Cycle resolution: cascading → daily → weekly → monthly → cascading.
|
|
pub fn cycleResolution(app: *App) void {
|
|
app.history_resolution = switch (app.history_resolution orelse {
|
|
// null means "default" — i.e. cascading. Step to daily.
|
|
app.history_resolution = .daily;
|
|
return;
|
|
}) {
|
|
.cascading => .daily,
|
|
.daily => .weekly,
|
|
.weekly => .monthly,
|
|
.monthly => null, // back to cascading default
|
|
};
|
|
}
|
|
|
|
/// If the cursor is on an expandable (non-daily, non-live)
|
|
/// bucket row, toggle its expanded state. Returns true if a
|
|
/// toggle happened (so the caller knows to consume the keypress
|
|
/// and trigger a redraw).
|
|
pub fn toggleTierAtCursor(app: *App) bool {
|
|
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const rows = collectTableRows(arena, app) catch return false;
|
|
if (app.history_cursor >= rows.len) return false;
|
|
const row = rows[app.history_cursor];
|
|
|
|
// Only buckets with a finer tier are expandable. Live rows,
|
|
// daily rows, and rows without bucket_start are not.
|
|
if (!row.has_children) return false;
|
|
const key = keyForRow(row) orelse return false;
|
|
|
|
if (app.history_expanded_buckets.contains(key)) {
|
|
_ = app.history_expanded_buckets.remove(key);
|
|
} else {
|
|
app.history_expanded_buckets.put(key, {}) catch return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Compare selection model ──────────────────────────────────
|
|
|
|
/// Returns the number of currently selected rows (0, 1, or 2).
|
|
fn selectionCount(app: *const App) usize {
|
|
var n: usize = 0;
|
|
for (app.history_selections) |s| {
|
|
if (s != null) n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
/// Returns true if row `idx` is currently selected.
|
|
fn isSelected(app: *const App, idx: usize) bool {
|
|
for (app.history_selections) |s| {
|
|
if (s) |v| if (v == idx) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Toggle selection of row `idx`. With two already selected and this
|
|
/// row isn't one of them, sets a status hint and leaves state alone.
|
|
fn toggleSelection(app: *App, idx: usize) void {
|
|
// Already selected? Remove.
|
|
for (&app.history_selections) |*slot| {
|
|
if (slot.*) |v| {
|
|
if (v == idx) {
|
|
slot.* = null;
|
|
setSelectionStatus(app);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// Find an empty slot.
|
|
for (&app.history_selections) |*slot| {
|
|
if (slot.* == null) {
|
|
slot.* = idx;
|
|
setSelectionStatus(app);
|
|
return;
|
|
}
|
|
}
|
|
// Both full, and this row isn't one of them.
|
|
app.setStatus("Two rows already selected — 's' on a selected row to deselect, or 'c' to compare");
|
|
}
|
|
|
|
/// Clear all selections.
|
|
fn clearSelections(app: *App) void {
|
|
app.history_selections = .{ null, null };
|
|
}
|
|
|
|
fn setSelectionStatus(app: *App) void {
|
|
const n = selectionCount(app);
|
|
switch (n) {
|
|
0 => app.setStatus(""),
|
|
1 => app.setStatus("Selected 1 row — select one more + press 'c' to compare"),
|
|
2 => app.setStatus("Selected 2 rows — press 'c' to compare"),
|
|
else => unreachable,
|
|
}
|
|
}
|
|
|
|
/// Public entry for the `compare_select` action dispatched via
|
|
/// matchAction. Internally delegates to the same toggle function the
|
|
/// intercept uses.
|
|
pub fn toggleSelectionAt(app: *App, idx: usize) void {
|
|
toggleSelection(app, idx);
|
|
}
|
|
|
|
/// Public entry for the `compare_commit` action.
|
|
pub fn commitCompareExternal(app: *App) void {
|
|
commitCompare(app);
|
|
}
|
|
|
|
/// Public entry for the `compare_cancel` action or internal Esc path:
|
|
/// clear selections and any active compare view.
|
|
pub fn clearCompareState(app: *App) void {
|
|
clearCompareView(app);
|
|
clearSelections(app);
|
|
app.setStatus("");
|
|
}
|
|
|
|
// ── Compare commit ───────────────────────────────────────────
|
|
|
|
/// Attempt to run compare. Called when the user presses `c`.
|
|
/// No-ops (with status hint) if the selection set isn't exactly 2.
|
|
fn commitCompare(app: *App) void {
|
|
const sel_count = selectionCount(app);
|
|
if (sel_count < 2) {
|
|
if (sel_count == 0) {
|
|
app.setStatus("Select two rows with 's' (or space), then press 'c' to compare");
|
|
} else {
|
|
app.setStatus("Select one more row with 's' (or space), then press 'c' to compare");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// At this point both slots are filled.
|
|
const sel_a = app.history_selections[0].?;
|
|
const sel_b = app.history_selections[1].?;
|
|
if (sel_a == sel_b) {
|
|
// Shouldn't happen via toggle logic, but guard anyway.
|
|
app.setStatus("Selected rows are the same — clear one and reselect");
|
|
return;
|
|
}
|
|
|
|
buildCompareFromSelections(app, sel_a, sel_b) catch |err| {
|
|
var msg_buf: [128]u8 = undefined;
|
|
// Translate the most common failure modes into actionable
|
|
// messages. We already short-circuit imported-only rows
|
|
// inside `buildCompareFromSelections`, so reaching the
|
|
// FileNotFound arm here means a snapshot the row claimed
|
|
// to have got moved or deleted between row collection
|
|
// and snapshot load.
|
|
const msg = switch (err) {
|
|
error.FileNotFound => "Compare failed: snapshot file is missing",
|
|
else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed",
|
|
};
|
|
app.setStatus(msg);
|
|
clearCompareView(app);
|
|
};
|
|
}
|
|
|
|
/// Build + stash the compare view from two selected row indices.
|
|
fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void {
|
|
// Resolve each row-index into its date and source (live vs
|
|
// snapshot). This requires re-computing the table row list the
|
|
// way the renderer does — shared helper keeps the two paths in
|
|
// sync.
|
|
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const rows = try collectTableRows(arena, app);
|
|
if (rows.len == 0 or sel_a >= rows.len or sel_b >= rows.len) {
|
|
app.setStatus("Stale selection — please re-select");
|
|
clearSelections(app);
|
|
return;
|
|
}
|
|
const row_a = rows[sel_a];
|
|
const row_b = rows[sel_b];
|
|
|
|
// Order: older → newer.
|
|
const older = if (row_a.date.days < row_b.date.days) row_a else row_b;
|
|
const newer = if (row_a.date.days < row_b.date.days) row_b else row_a;
|
|
|
|
// Imported-only rows have no on-disk snapshot — the historical
|
|
// value came from `imported_values.srf`, not a real portfolio
|
|
// snapshot. We can't build a per-symbol compare from that
|
|
// (no lots, no per-symbol prices). Bail out with a friendly
|
|
// status before we try (and fail) to open the snapshot file.
|
|
if (older.imported_only or newer.imported_only) {
|
|
app.setStatus("Cannot compare: imported-only history has no per-symbol detail");
|
|
clearSelections(app);
|
|
return;
|
|
}
|
|
|
|
// Build up the resources + maps for each side.
|
|
var resources: tui.HistoryCompareResources = .{};
|
|
errdefer resources.deinit(app.allocator);
|
|
|
|
const portfolio_path = app.portfolio_path orelse {
|
|
app.setStatus("No portfolio loaded — can't build compare");
|
|
return error.PortfolioLoadFailed;
|
|
};
|
|
const hist_dir = try history.deriveHistoryDir(app.allocator, portfolio_path);
|
|
defer app.allocator.free(hist_dir);
|
|
|
|
// SAFETY: assigned in both branches of the `if (older.is_live)`
|
|
// below before first read (via `then_map_ptr.*` when passed to
|
|
// buildCompareView).
|
|
var then_map_ptr: *compare_view.HoldingMap = undefined;
|
|
var then_liquid: f64 = 0;
|
|
// "Then" side.
|
|
if (older.is_live) {
|
|
var map: compare_view.HoldingMap = .init(app.allocator);
|
|
errdefer map.deinit();
|
|
try aggregateFromSummary(app.portfolio_summary.?, &map);
|
|
resources.then_live_map = map;
|
|
then_map_ptr = &resources.then_live_map.?;
|
|
then_liquid = liveLiquid(app);
|
|
} else {
|
|
const side = try compare_core.loadSnapshotSide(app.io, app.allocator, hist_dir, older.date);
|
|
resources.then_snap = side;
|
|
then_map_ptr = &resources.then_snap.?.map;
|
|
then_liquid = side.liquid;
|
|
}
|
|
|
|
// SAFETY: assigned in both branches of the `if (newer.is_live)`
|
|
// below before first read.
|
|
var now_map_ptr: *compare_view.HoldingMap = undefined;
|
|
var now_liquid: f64 = 0;
|
|
if (newer.is_live) {
|
|
var map: compare_view.HoldingMap = .init(app.allocator);
|
|
errdefer map.deinit();
|
|
try aggregateFromSummary(app.portfolio_summary.?, &map);
|
|
resources.now_live_map = map;
|
|
now_map_ptr = &resources.now_live_map.?;
|
|
now_liquid = liveLiquid(app);
|
|
} else {
|
|
const side = try compare_core.loadSnapshotSide(app.io, app.allocator, hist_dir, newer.date);
|
|
resources.now_snap = side;
|
|
now_map_ptr = &resources.now_snap.?.map;
|
|
now_liquid = side.liquid;
|
|
}
|
|
|
|
// "now is live" only when the NEWER endpoint is the live row.
|
|
const now_is_live = newer.is_live;
|
|
|
|
const cv = try compare_view.buildCompareView(
|
|
app.allocator,
|
|
older.date,
|
|
newer.date,
|
|
now_is_live,
|
|
then_liquid,
|
|
now_liquid,
|
|
then_map_ptr,
|
|
now_map_ptr,
|
|
);
|
|
// No error paths between here and install, so no errdefer needed.
|
|
// If buildCompareView returned Ok, we own cv's backing memory
|
|
// (allocated inside buildCompareView) and pass ownership to the App.
|
|
|
|
// Bucket-aware labels: when either selected row was a tier
|
|
// bucket (weekly/monthly/quarterly/yearly), render
|
|
// "Q1 2025 (ended 2025-03-28)" instead of the bare ISO date.
|
|
// Allocates into app.allocator; CompareView.deinit frees.
|
|
var cv_with_labels = cv;
|
|
cv_with_labels.then_label = try compare_view.buildBucketLabel(
|
|
app.allocator,
|
|
older.tier,
|
|
older.bucket_start,
|
|
older.date,
|
|
older.is_live,
|
|
);
|
|
cv_with_labels.now_label = try compare_view.buildBucketLabel(
|
|
app.allocator,
|
|
newer.tier,
|
|
newer.bucket_start,
|
|
newer.date,
|
|
newer.is_live,
|
|
);
|
|
|
|
// Commit: install both onto the App. From this point onwards the
|
|
// App owns them and clearCompareView handles teardown.
|
|
clearCompareView(app);
|
|
app.history_compare_view = cv_with_labels;
|
|
app.history_compare_resources = resources;
|
|
app.setStatus("Comparing — Esc or 'c' to return to timeline");
|
|
}
|
|
|
|
fn liveLiquid(app: *const App) f64 {
|
|
if (app.portfolio_summary) |s| return s.total_value;
|
|
return 0;
|
|
}
|
|
|
|
/// Aggregate the live portfolio's per-symbol holdings from the
|
|
/// already-computed `PortfolioSummary.allocations`.
|
|
///
|
|
/// We derive the post-`price_ratio` per-share price from
|
|
/// `market_value / shares` rather than reading raw prices + applying
|
|
/// ratio ourselves. This matches the snapshot aggregation (which
|
|
/// stores post-ratio prices in its lot rows) and keeps the two
|
|
/// endpoints apples-to-apples. Edge case: covered-call adjustments
|
|
/// (`adjustForCoveredCalls`) skew the derived per-share price
|
|
/// slightly for symbols with ITM sold calls; matches the CLI behavior
|
|
/// and is acceptable.
|
|
fn aggregateFromSummary(
|
|
summary: zfin.valuation.PortfolioSummary,
|
|
out: *compare_view.HoldingMap,
|
|
) !void {
|
|
for (summary.allocations) |a| {
|
|
if (a.shares == 0) continue;
|
|
const price = a.market_value / a.shares;
|
|
try out.put(a.symbol, .{ .shares = a.shares, .price = price });
|
|
}
|
|
}
|
|
|
|
// ── Compare key handler (intercepted from tui.zig) ───────────
|
|
|
|
/// Intercepted before `matchAction` runs when the active tab is
|
|
/// history. Returns true if the key was consumed.
|
|
pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) bool {
|
|
// Escape: exit compare view, or clear selections.
|
|
if (key.codepoint == vaxis.Key.escape) {
|
|
if (app.history_compare_view != null) {
|
|
clearCompareView(app);
|
|
clearSelections(app);
|
|
app.setStatus("");
|
|
ctx.consumeAndRedraw();
|
|
return true;
|
|
}
|
|
if (selectionCount(app) > 0) {
|
|
clearSelections(app);
|
|
app.setStatus("");
|
|
ctx.consumeAndRedraw();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// 's' or space: toggle selection on the cursor row.
|
|
if (key.matches('s', .{}) or key.matches(vaxis.Key.space, .{})) {
|
|
// Disabled while the compare view is up — Esc to return first.
|
|
if (app.history_compare_view != null) return false;
|
|
if (app.history_table_row_count == 0) return false;
|
|
toggleSelection(app, app.history_cursor);
|
|
ctx.consumeAndRedraw();
|
|
return true;
|
|
}
|
|
|
|
// 'c': commit compare, or exit compare view if already active.
|
|
if (key.matches('c', .{})) {
|
|
if (app.history_compare_view != null) {
|
|
clearCompareView(app);
|
|
clearSelections(app);
|
|
app.setStatus("");
|
|
ctx.consumeAndRedraw();
|
|
return true;
|
|
}
|
|
commitCompare(app);
|
|
ctx.consumeAndRedraw();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ── Table row model ──────────────────────────────────────────
|
|
|
|
/// One row in the rendered recent-snapshots table.
|
|
///
|
|
/// `is_live` is true for the synthesized "today (live)" pseudo-row
|
|
/// at index 0 when the TUI has portfolio-summary state loaded.
|
|
///
|
|
/// `tier`, when set on a non-header row, identifies which tier
|
|
/// the bucket belongs to (used to render a date label like
|
|
/// "W of YYYY-MM-DD" / "Q1 YYYY") and to decide drill-down
|
|
/// behavior.
|
|
///
|
|
/// `imported_only` flags rows where illiquid/net_worth are
|
|
/// unavailable (data came from imported_values.srf). Renderers
|
|
/// substitute `—` for those cells.
|
|
///
|
|
/// `expanded` and `has_children` drive the per-bucket drill-down.
|
|
/// A non-daily bucket can be expanded (clicked / Enter on cursor)
|
|
/// to reveal its child buckets at the next-finer tier.
|
|
///
|
|
/// `indent` is how deep the row sits in the drill-down tree
|
|
/// (0 = top-level, 1 = first drill-down level, etc.). The
|
|
/// renderer uses this to indent the date column visually.
|
|
///
|
|
/// `tier_header` is retained for API compatibility but no
|
|
/// longer populated by the cascading collector — every row in
|
|
/// the new model is a bucket row.
|
|
pub const TableRow = struct {
|
|
date: zfin.Date,
|
|
is_live: bool,
|
|
liquid: f64,
|
|
illiquid: f64,
|
|
net_worth: f64,
|
|
d_liquid: ?f64,
|
|
d_illiquid: ?f64,
|
|
d_net_worth: ?f64,
|
|
/// Deprecated — always null in the cascading collector. Kept
|
|
/// for compatibility with older test fixtures.
|
|
tier_header: ?TierHeader = null,
|
|
tier: ?timeline.Tier = null,
|
|
imported_only: bool = false,
|
|
bucket_start: ?zfin.Date = null,
|
|
bucket_end: ?zfin.Date = null,
|
|
/// True when the user has expanded this bucket. Daily and
|
|
/// imported_only-only buckets cannot be expanded.
|
|
expanded: bool = false,
|
|
/// True when this bucket has a finer-tier breakdown
|
|
/// available (any non-daily tier has children).
|
|
has_children: bool = false,
|
|
/// Drill-down depth. 0 for top-level rows; >0 for rows
|
|
/// emitted as children of an expanded bucket.
|
|
indent: u8 = 0,
|
|
};
|
|
|
|
/// Tier header metadata. Rendering displays a chevron + tier
|
|
/// label + bucket count.
|
|
pub const TierHeader = struct {
|
|
tier: timeline.Tier,
|
|
collapsed: bool,
|
|
bucket_count: u32,
|
|
};
|
|
|
|
/// Build the list of table rows in display order (newest-first).
|
|
///
|
|
/// Row 0 is the live pseudo-row if available, followed by all snapshot
|
|
/// rows from newest to oldest. The `RowDelta` slice from
|
|
/// `computeRowDeltas` is oldest-first; we reverse it and optionally
|
|
/// prepend the live row.
|
|
pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow {
|
|
const timeline_opt = app.history_timeline;
|
|
if (timeline_opt == null) return &.{};
|
|
const series = timeline_opt.?.series;
|
|
if (series.points.len == 0) return &.{};
|
|
|
|
// Resolution dispatch:
|
|
// - explicit non-cascading: use the legacy flat-aggregation
|
|
// path (preserves existing behavior for daily/weekly/monthly).
|
|
// - explicit cascading OR null (default): build the tiered view.
|
|
const explicit = app.history_resolution;
|
|
const use_cascading = explicit == null or explicit.? == .cascading;
|
|
|
|
if (!use_cascading) {
|
|
return collectFlatTableRows(arena, app, series, explicit.?);
|
|
}
|
|
return collectCascadingTableRows(arena, app, series);
|
|
}
|
|
|
|
/// Legacy path: flat aggregation by single resolution. Preserved
|
|
/// for `--resolution daily/weekly/monthly` invocations.
|
|
fn collectFlatTableRows(
|
|
arena: std.mem.Allocator,
|
|
app: *const App,
|
|
series: timeline.TimelineSeries,
|
|
resolution: timeline.Resolution,
|
|
) ![]TableRow {
|
|
const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
|
|
const deltas = try timeline.computeRowDeltas(arena, aggregated);
|
|
|
|
// Decide whether to prepend a live pseudo-row. Requires both a
|
|
// portfolio (for illiquid) and a summary (for liquid total).
|
|
const live_opt = buildLiveRow(app, deltas);
|
|
|
|
var list: std.ArrayList(TableRow) = .empty;
|
|
try list.ensureTotalCapacity(arena, deltas.len + (if (live_opt == null) @as(usize, 0) else @as(usize, 1)));
|
|
|
|
if (live_opt) |live| try list.append(arena, live);
|
|
|
|
// deltas is oldest-first; emit newest-first.
|
|
var i: usize = deltas.len;
|
|
while (i > 0) {
|
|
i -= 1;
|
|
const d = deltas[i];
|
|
try list.append(arena, .{
|
|
.date = d.date,
|
|
.is_live = false,
|
|
.liquid = d.liquid,
|
|
.illiquid = d.illiquid,
|
|
.net_worth = d.net_worth,
|
|
.d_liquid = d.d_liquid,
|
|
.d_illiquid = d.d_illiquid,
|
|
.d_net_worth = d.d_net_worth,
|
|
});
|
|
}
|
|
|
|
return list.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// New path: cascading aggregation. Produces a flat row list
|
|
/// where each top-level bucket is its own row; expanded buckets
|
|
/// are followed by their child buckets at the next-finer tier
|
|
/// (recursively, so an expanded year reveals quarters which can
|
|
/// reveal months which can reveal weeks which can reveal days).
|
|
///
|
|
/// Daily rows for the last 14 days are top-level (no parent
|
|
/// bucket — they're already at leaf granularity).
|
|
fn collectCascadingTableRows(
|
|
arena: std.mem.Allocator,
|
|
app: *const App,
|
|
series: timeline.TimelineSeries,
|
|
) ![]TableRow {
|
|
const ts = try timeline.aggregateCascading(arena, series.points, app.today);
|
|
|
|
// Live row (today's live state) — same as flat path.
|
|
const live_opt = buildLiveRowFromCascading(app, ts.buckets);
|
|
|
|
var list: std.ArrayList(TableRow) = .empty;
|
|
try list.ensureTotalCapacity(arena, ts.buckets.len + 8);
|
|
|
|
if (live_opt) |live| try list.append(arena, live);
|
|
|
|
// Pre-compute Δs for the top-level buckets so each row
|
|
// shows "this bucket vs. its older neighbor."
|
|
const top_deltas = try timeline.computeBucketDeltas(arena, ts.buckets);
|
|
|
|
for (ts.buckets, 0..) |b, idx| {
|
|
const d = top_deltas[idx];
|
|
const expanded = app.history_expanded_buckets.contains(keyFor(b));
|
|
try list.append(arena, .{
|
|
.date = b.representative_date,
|
|
.is_live = false,
|
|
.liquid = b.liquid,
|
|
.illiquid = b.illiquid,
|
|
.net_worth = b.net_worth,
|
|
.d_liquid = d.delta_liquid,
|
|
.d_illiquid = d.delta_illiquid,
|
|
.d_net_worth = d.delta_net_worth,
|
|
.tier_header = null,
|
|
.tier = b.tier,
|
|
.imported_only = b.imported_only,
|
|
.bucket_start = b.bucket_start,
|
|
.bucket_end = b.bucket_end,
|
|
.expanded = expanded,
|
|
.has_children = timeline.finerTier(b.tier) != null,
|
|
.indent = 0,
|
|
});
|
|
|
|
if (expanded) {
|
|
try emitChildren(arena, app, series.points, b, &list, 1);
|
|
}
|
|
}
|
|
|
|
return list.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// Recursively emit child rows for a parent bucket. Used both
|
|
/// at top level and when a child is itself expanded.
|
|
fn emitChildren(
|
|
arena: std.mem.Allocator,
|
|
app: *const App,
|
|
series: []const timeline.TimelinePoint,
|
|
parent: timeline.TierBucket,
|
|
list: *std.ArrayList(TableRow),
|
|
indent: u8,
|
|
) !void {
|
|
const children = try timeline.childBuckets(arena, parent);
|
|
if (children.len == 0) return;
|
|
const child_deltas = try timeline.computeBucketDeltas(arena, children);
|
|
|
|
// The oldest child gets a null Δ from `computeBucketDeltas`
|
|
// because there's no older neighbor inside the local slice.
|
|
// But within the wider series there usually IS an older
|
|
// point (just before `parent.bucket_start`); use it so the
|
|
// bottom row shows a meaningful Δ instead of greyed-out null.
|
|
// Only the very-first data point in the entire series should
|
|
// legitimately have a null Δ.
|
|
const oldest_idx = children.len - 1;
|
|
const oldest = children[oldest_idx];
|
|
const prior_opt = priorPointBefore(series, oldest.bucket_start);
|
|
const oldest_delta: timeline.BucketDelta = if (prior_opt) |prior| blk: {
|
|
const dl: ?f64 = oldest.liquid - prior.liquid;
|
|
// Imported-only crossings still null out illiquid /
|
|
// net_worth Δs.
|
|
const cross_imported = oldest.imported_only or prior.source == .imported;
|
|
const di: ?f64 = if (cross_imported) null else oldest.illiquid - prior.illiquid;
|
|
const dn: ?f64 = if (cross_imported) null else oldest.net_worth - prior.net_worth;
|
|
break :blk .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn };
|
|
} else child_deltas[oldest_idx];
|
|
|
|
for (children, 0..) |c, idx| {
|
|
const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx];
|
|
const expanded = app.history_expanded_buckets.contains(keyFor(c));
|
|
try list.append(arena, .{
|
|
.date = c.representative_date,
|
|
.is_live = false,
|
|
.liquid = c.liquid,
|
|
.illiquid = c.illiquid,
|
|
.net_worth = c.net_worth,
|
|
.d_liquid = d.delta_liquid,
|
|
.d_illiquid = d.delta_illiquid,
|
|
.d_net_worth = d.delta_net_worth,
|
|
.tier_header = null,
|
|
.tier = c.tier,
|
|
.imported_only = c.imported_only,
|
|
.bucket_start = c.bucket_start,
|
|
.bucket_end = c.bucket_end,
|
|
.expanded = expanded,
|
|
.has_children = timeline.finerTier(c.tier) != null,
|
|
.indent = indent,
|
|
});
|
|
|
|
if (expanded and timeline.finerTier(c.tier) != null) {
|
|
try emitChildren(arena, app, series, c, list, indent + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the most recent series point with `as_of_date < target`.
|
|
/// Returns null when `target` precedes every point in the series
|
|
/// — i.e., this would be the very first data point overall, the
|
|
/// only case where a null Δ is correct.
|
|
///
|
|
/// `series` is date-ascending (built that way by
|
|
/// `timeline.buildMergedSeries`); we walk backward from the end
|
|
/// for an early exit on typical drill-down workloads where the
|
|
/// target is recent.
|
|
fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?timeline.TimelinePoint {
|
|
var i: usize = series.len;
|
|
while (i > 0) {
|
|
i -= 1;
|
|
if (series[i].as_of_date.days < target.days) return series[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Build the live row for the cascading path. Uses the newest
|
|
/// bucket (typically a daily bucket) as the comparison anchor.
|
|
fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow {
|
|
if (app.portfolio == null) return null;
|
|
const summary = app.portfolio_summary orelse return null;
|
|
|
|
const liquid = summary.total_value;
|
|
const illiquid = app.portfolio.?.totalIlliquid(app.today);
|
|
const net_worth = liquid + illiquid;
|
|
|
|
var d_liquid: ?f64 = null;
|
|
var d_illiquid: ?f64 = null;
|
|
var d_net_worth: ?f64 = null;
|
|
if (buckets.len > 0) {
|
|
const b = buckets[0];
|
|
d_liquid = liquid - b.liquid;
|
|
if (!b.imported_only) {
|
|
d_illiquid = illiquid - b.illiquid;
|
|
d_net_worth = net_worth - b.net_worth;
|
|
}
|
|
}
|
|
|
|
return .{
|
|
.date = app.today,
|
|
.is_live = true,
|
|
.liquid = liquid,
|
|
.illiquid = illiquid,
|
|
.net_worth = net_worth,
|
|
.d_liquid = d_liquid,
|
|
.d_illiquid = d_illiquid,
|
|
.d_net_worth = d_net_worth,
|
|
};
|
|
}
|
|
|
|
/// Build the synthetic "today (live)" row from App state, or return
|
|
/// null if the required state isn't populated.
|
|
///
|
|
/// Deltas are computed against the newest snapshot in `deltas` (which
|
|
/// is index `deltas.len - 1` — deltas is oldest-first).
|
|
fn buildLiveRow(app: *const App, deltas: []const timeline.RowDelta) ?TableRow {
|
|
if (app.portfolio == null) return null;
|
|
const summary = app.portfolio_summary orelse return null;
|
|
|
|
const liquid = summary.total_value;
|
|
const illiquid = app.portfolio.?.totalIlliquid(app.today);
|
|
const net_worth = liquid + illiquid;
|
|
|
|
// Deltas vs. the most recent snapshot.
|
|
var d_liquid: ?f64 = null;
|
|
var d_illiquid: ?f64 = null;
|
|
var d_net_worth: ?f64 = null;
|
|
if (deltas.len > 0) {
|
|
const newest = deltas[deltas.len - 1];
|
|
d_liquid = liquid - newest.liquid;
|
|
d_illiquid = illiquid - newest.illiquid;
|
|
d_net_worth = net_worth - newest.net_worth;
|
|
}
|
|
|
|
return .{
|
|
.date = app.today,
|
|
.is_live = true,
|
|
.liquid = liquid,
|
|
.illiquid = illiquid,
|
|
.net_worth = net_worth,
|
|
.d_liquid = d_liquid,
|
|
.d_illiquid = d_illiquid,
|
|
.d_net_worth = d_net_worth,
|
|
};
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────
|
|
|
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
// Compare mode short-circuits the timeline render.
|
|
if (app.history_compare_view) |cv| {
|
|
// Compare view doesn't populate table metadata; reset so the
|
|
// cursor state remains sensible if the user exits back.
|
|
return renderCompareLines(arena, app.theme, cv);
|
|
}
|
|
|
|
const rows = try collectTableRows(arena, app);
|
|
const result = try renderHistoryLinesFull(
|
|
arena,
|
|
app.theme,
|
|
if (app.history_timeline) |tl| tl.series else null,
|
|
app.history_metric,
|
|
app.history_resolution,
|
|
rows,
|
|
app.history_cursor,
|
|
app.history_selections,
|
|
);
|
|
|
|
// Stash table metadata on the App for the event handler's cursor-
|
|
// visibility logic.
|
|
app.history_table_first_line = result.table_first_line;
|
|
app.history_table_row_count = result.table_row_count;
|
|
|
|
// Clamp cursor in case the row count shrank since the last press
|
|
// (shouldn't happen often but guards against subtle off-by-ones).
|
|
if (result.table_row_count == 0) {
|
|
app.history_cursor = 0;
|
|
} else if (app.history_cursor >= result.table_row_count) {
|
|
app.history_cursor = result.table_row_count - 1;
|
|
}
|
|
|
|
return result.lines;
|
|
}
|
|
|
|
/// Thin wrapper so existing tests calling `renderHistoryLines`
|
|
/// continue to pass: no cursor, no selections, no live row, no
|
|
/// metadata. Internal callers should prefer `renderHistoryLinesFull`.
|
|
pub fn renderHistoryLines(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
series_opt: ?timeline.TimelineSeries,
|
|
focus_metric: timeline.Metric,
|
|
resolution_override: ?timeline.Resolution,
|
|
) ![]const StyledLine {
|
|
const rows = try rowsFromSeries(arena, series_opt, resolution_override);
|
|
const result = try renderHistoryLinesFull(
|
|
arena,
|
|
th,
|
|
series_opt,
|
|
focus_metric,
|
|
resolution_override,
|
|
rows,
|
|
0,
|
|
.{ null, null },
|
|
);
|
|
return result.lines;
|
|
}
|
|
|
|
fn rowsFromSeries(
|
|
arena: std.mem.Allocator,
|
|
series_opt: ?timeline.TimelineSeries,
|
|
resolution_override: ?timeline.Resolution,
|
|
) ![]TableRow {
|
|
const series = series_opt orelse return &.{};
|
|
if (series.points.len == 0) return &.{};
|
|
const resolution = resolution_override orelse timeline.selectResolution(series.points);
|
|
const aggregated = try timeline.aggregatePoints(arena, series.points, resolution);
|
|
const deltas = try timeline.computeRowDeltas(arena, aggregated);
|
|
|
|
var list: std.ArrayList(TableRow) = .empty;
|
|
try list.ensureTotalCapacity(arena, deltas.len);
|
|
var i: usize = deltas.len;
|
|
while (i > 0) {
|
|
i -= 1;
|
|
const d = deltas[i];
|
|
try list.append(arena, .{
|
|
.date = d.date,
|
|
.is_live = false,
|
|
.liquid = d.liquid,
|
|
.illiquid = d.illiquid,
|
|
.net_worth = d.net_worth,
|
|
.d_liquid = d.d_liquid,
|
|
.d_illiquid = d.d_illiquid,
|
|
.d_net_worth = d.d_net_worth,
|
|
});
|
|
}
|
|
return list.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// Full render result: the styled lines plus metadata the event
|
|
/// handler needs for cursor-visibility logic.
|
|
pub const HistoryRender = struct {
|
|
lines: []const StyledLine,
|
|
/// Line index of the first data row in the recent-snapshots table.
|
|
/// Zero when the table isn't rendered (no data).
|
|
table_first_line: usize,
|
|
/// Number of data rows in the table (includes live pseudo-row).
|
|
table_row_count: usize,
|
|
};
|
|
|
|
/// Full renderer: takes cursor + selections + prebuilt rows (already
|
|
/// in display order, newest-first) and returns styled lines plus
|
|
/// cursor metadata. Pure — no App dependency — so tests can drive it.
|
|
pub fn renderHistoryLinesFull(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
series_opt: ?timeline.TimelineSeries,
|
|
focus_metric: timeline.Metric,
|
|
resolution_override: ?timeline.Resolution,
|
|
rows: []const TableRow,
|
|
cursor: usize,
|
|
selections: [2]?usize,
|
|
) !HistoryRender {
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Portfolio History", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const series = series_opt orelse {
|
|
try lines.append(arena, .{ .text = " No history snapshots yet.", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " Run: zfin snapshot (or wait for the daily cron)", .style = th.mutedStyle() });
|
|
return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 };
|
|
};
|
|
|
|
const points = series.points;
|
|
if (points.len == 0) {
|
|
try lines.append(arena, .{ .text = " No snapshots found.", .style = th.mutedStyle() });
|
|
return .{ .lines = try lines.toOwnedSlice(arena), .table_first_line = 0, .table_row_count = 0 };
|
|
}
|
|
|
|
const metric_label = focus_metric.label();
|
|
|
|
// ── Windows block (focused metric only) ──────────────────────
|
|
try appendWindowsBlock(arena, &lines, th, points, focus_metric, metric_label);
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Chart ────────────────────────────────────────────────────
|
|
const chart_header = try std.fmt.allocPrint(
|
|
arena,
|
|
" Chart: {s} (press 'm' to cycle metric, 't' to cycle resolution)",
|
|
.{metric_label},
|
|
);
|
|
try lines.append(arena, .{ .text = chart_header, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Chart: synthesize candles from the focused metric's value.
|
|
// For illiquid / net_worth, skip imported-only points so the
|
|
// line is visually absent in the imported-only range rather
|
|
// than hugging zero.
|
|
var candles_list: std.ArrayList(zfin.Candle) = .empty;
|
|
try candles_list.ensureTotalCapacity(arena, points.len);
|
|
const skip_imported = (focus_metric == .illiquid) or (focus_metric == .net_worth);
|
|
for (points) |p| {
|
|
if (skip_imported and p.source == .imported) continue;
|
|
const value = extractOne(p, focus_metric);
|
|
try candles_list.append(arena, .{
|
|
.date = p.as_of_date,
|
|
.open = value,
|
|
.high = value,
|
|
.low = value,
|
|
.close = value,
|
|
.adj_close = value,
|
|
.volume = 0,
|
|
});
|
|
}
|
|
const candles = candles_list.items;
|
|
try tui.renderBrailleToStyledLines(arena, &lines, candles, th);
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Recent snapshots table ───────────────────────────────────
|
|
// Resolve the displayed resolution label: explicit override
|
|
// wins, otherwise default is cascading (matches the
|
|
// rendering in `collectTableRows`).
|
|
const resolution: timeline.Resolution = if (resolution_override) |r| r else .cascading;
|
|
|
|
var rlabel_buf: [32]u8 = undefined;
|
|
const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution);
|
|
const table_header = try std.fmt.allocPrint(
|
|
arena,
|
|
" Recent snapshots {s} (j/k: move, enter: expand/collapse, s/space: select, c: compare)",
|
|
.{rlabel},
|
|
);
|
|
try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Column header: extra 2-char left margin for the selection
|
|
// marker; "Date" itself indented 2 more chars so the column
|
|
// text aligns with bucket date labels (which carry a 2-space
|
|
// chevron-or-spacer prefix). 28-col slot matches `fmtTableRow`.
|
|
const header_line = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:<28} {s:>31} {s:>31} {s:>31}",
|
|
.{ " Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" },
|
|
);
|
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
|
|
|
const table_first_line = lines.items.len;
|
|
|
|
for (rows, 0..) |row, idx| {
|
|
const is_cursor = idx == cursor;
|
|
const selected = isIndexSelected(selections, idx);
|
|
|
|
const text = try fmtTableRow(arena, row, selected);
|
|
const style = if (is_cursor)
|
|
th.selectStyle()
|
|
else if (selected)
|
|
th.headerStyle()
|
|
else
|
|
rowStyle(th, row, focus_metric);
|
|
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
|
|
return .{
|
|
.lines = try lines.toOwnedSlice(arena),
|
|
.table_first_line = table_first_line,
|
|
.table_row_count = rows.len,
|
|
};
|
|
}
|
|
|
|
fn isIndexSelected(selections: [2]?usize, idx: usize) bool {
|
|
for (selections) |s| {
|
|
if (s) |v| if (v == idx) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Render the rolling-windows block into `lines`. Output matches the
|
|
/// CLI byte-for-byte (modulo ANSI) — same widths, same labels, same
|
|
/// dashed divider — because both call `view.buildWindowRowCells`.
|
|
fn appendWindowsBlock(
|
|
arena: std.mem.Allocator,
|
|
lines: *std.ArrayList(StyledLine),
|
|
th: theme.Theme,
|
|
points: []const timeline.TimelinePoint,
|
|
metric: timeline.Metric,
|
|
metric_label: []const u8,
|
|
) !void {
|
|
_ = metric_label;
|
|
|
|
const today = points[points.len - 1].as_of_date;
|
|
const ws = try timeline.computeWindowSet(arena, points, metric, today);
|
|
|
|
try lines.append(arena, .{ .text = " Change", .style = th.headerStyle() });
|
|
|
|
const header_line = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:<12} {s:>18} {s:>10} {s:>10}",
|
|
.{ "", "Δ", "%", "% / yr" },
|
|
);
|
|
try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() });
|
|
try lines.append(arena, .{
|
|
.text = " ------------ ------------------ ---------- ----------",
|
|
.style = th.mutedStyle(),
|
|
});
|
|
|
|
for (ws.rows) |row| {
|
|
var dbuf: [32]u8 = undefined;
|
|
var pbuf: [16]u8 = undefined;
|
|
var abuf: [16]u8 = undefined;
|
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf);
|
|
|
|
const text = try std.fmt.allocPrint(
|
|
arena,
|
|
" {s:<12} {s:>18} {s:>10} {s:>10}",
|
|
.{ cells.label, cells.delta_str, cells.pct_str, cells.ann_str },
|
|
);
|
|
const style: vaxis.Cell.Style = th.styleFor(cells.style);
|
|
try lines.append(arena, .{ .text = text, .style = style });
|
|
}
|
|
}
|
|
|
|
/// Build a recent-snapshots table row. `selected` causes a `*` marker
|
|
/// to appear in the two-column left margin instead of spaces.
|
|
///
|
|
/// All rows align column-for-column regardless of drill-down depth:
|
|
/// the value cells (Liquid/Illiquid/Net Worth) sit at fixed column
|
|
/// positions so their closing `)` characters line up vertically
|
|
/// across the whole table.
|
|
///
|
|
/// Drill-down depth is conveyed inside the date column only — by
|
|
/// indenting the chevron+label within the 20-byte date slot. The
|
|
/// trade-off: if a deeply-drilled label overflows 20 bytes (e.g.
|
|
/// ` ▶ W of 2024-03-31`), the value columns shift right just
|
|
/// for that row. We mitigate by capping the per-level indent at
|
|
/// 2 columns and shrinking the slot use.
|
|
fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 {
|
|
// Legacy: tier_header rows (now unused). Kept so old test
|
|
// fixtures still render something sensible.
|
|
if (row.tier_header) |th| {
|
|
const chevron: []const u8 = if (th.collapsed) "▶" else "▼";
|
|
return std.fmt.allocPrint(
|
|
arena,
|
|
" {s} {s} ({d})",
|
|
.{ chevron, @tagName(th.tier), th.bucket_count },
|
|
);
|
|
}
|
|
|
|
var date_buf: [40]u8 = undefined;
|
|
var liq_cell_buf: [64]u8 = undefined;
|
|
var ill_cell_buf: [64]u8 = undefined;
|
|
var nw_cell_buf: [64]u8 = undefined;
|
|
|
|
// Build the date cell content. Drill-down depth is shown by
|
|
// leading spaces *inside* the date slot, NOT by indenting
|
|
// the whole row — so values stay aligned column-for-column.
|
|
const date_s: []const u8 = if (row.is_live)
|
|
// Write into `date_buf` so the in-place padding below
|
|
// can extend it.
|
|
std.fmt.bufPrint(&date_buf, " today", .{}) catch " today"
|
|
else if (row.tier) |t| blk: {
|
|
var inner_buf: [32]u8 = undefined;
|
|
// Bucket rows from cascading mode carry `bucket_start`;
|
|
// legacy daily rows from flat-resolution don't, so we
|
|
// fall back to `row.date` (the row's representative
|
|
// date — same value as bucket_start for daily buckets).
|
|
const start = row.bucket_start orelse row.date;
|
|
const lbl = timeline.formatBucketLabel(&inner_buf, t, start);
|
|
// Compute indent prefix: 2 spaces per indent level, capped
|
|
// at 16 to keep the date cell from blowing past its 28-col
|
|
// budget. (Trees go 4-5 levels max in practice.)
|
|
const indent_pool = " "; // 16 spaces
|
|
const indent_cols = @min(@as(usize, row.indent) * 2, indent_pool.len);
|
|
const lead = indent_pool[0..indent_cols];
|
|
if (row.has_children) {
|
|
const chev: []const u8 = if (row.expanded) "▼" else "▶";
|
|
break :blk std.fmt.bufPrint(
|
|
&date_buf,
|
|
"{s}{s} {s}",
|
|
.{ lead, chev, lbl },
|
|
) catch lbl;
|
|
}
|
|
// No expand chevron: 2 leading spaces (to align with
|
|
// chevron-bearing rows at the same indent level).
|
|
break :blk std.fmt.bufPrint(
|
|
&date_buf,
|
|
"{s} {s}",
|
|
.{ lead, lbl },
|
|
) catch lbl;
|
|
} else blk: {
|
|
break :blk std.fmt.bufPrint(&date_buf, " {f}", .{row.date}) catch "????-??-??";
|
|
};
|
|
|
|
const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width);
|
|
const ill_cell = if (row.imported_only)
|
|
fmt.centerDash(&ill_cell_buf, view.table_cell_width)
|
|
else
|
|
view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width);
|
|
const nw_cell = if (row.imported_only)
|
|
fmt.centerDash(&nw_cell_buf, view.table_cell_width)
|
|
else
|
|
view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width);
|
|
|
|
const marker: []const u8 = if (selected) "* " else " ";
|
|
// Pad date_s to a consistent *display* width of 28 columns
|
|
// (wide enough to fit the deepest drilldown label, which is
|
|
// ` ▶ W of YYYY-MM-DD` ~ 25 cols when fully drilled).
|
|
// Zig's `{s:<N}` pads by bytes, which under-pads rows that
|
|
// contain the multi-byte chevron (▶/▼ are 3 bytes / 1 col);
|
|
// `fmt.padRightToCols` accounts for display width.
|
|
//
|
|
// `date_s` already lives at the start of `date_buf`, so the
|
|
// in-place pad just appends spaces after it.
|
|
const date_padded = fmt.padRightToCols(&date_buf, date_s, 28);
|
|
return std.fmt.allocPrint(
|
|
arena,
|
|
" {s}{s} {s} {s} {s}",
|
|
.{ marker, date_padded, liq_cell, ill_cell, nw_cell },
|
|
);
|
|
}
|
|
|
|
fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style {
|
|
if (row.tier_header != null) return th.mutedStyle();
|
|
const d_opt: ?f64 = switch (metric) {
|
|
.liquid => row.d_liquid,
|
|
.illiquid => row.d_illiquid,
|
|
.net_worth => row.d_net_worth,
|
|
};
|
|
if (d_opt) |d| {
|
|
if (d < 0) return th.negativeStyle();
|
|
if (d > 0) return th.positiveStyle();
|
|
}
|
|
return th.mutedStyle();
|
|
}
|
|
|
|
fn extractOne(p: timeline.TimelinePoint, metric: timeline.Metric) f64 {
|
|
return switch (metric) {
|
|
.net_worth => p.net_worth,
|
|
.liquid => p.liquid,
|
|
.illiquid => p.illiquid,
|
|
};
|
|
}
|
|
|
|
// ── Compare-mode rendering ────────────────────────────────────
|
|
//
|
|
// Thin adapter: pulls pre-formatted cells from `views/compare.zig`
|
|
// and drops them into vaxis-styled lines. Layout widths, number
|
|
// formatting, and label pluralization all come from the view layer —
|
|
// this function owns only the TUI-specific style mapping (via
|
|
// `theme.styleFor`) and the " " leading indent that matches the
|
|
// rest of the history tab. The CLI renderer in `commands/compare.zig`
|
|
// uses the same shared cells + format constants, so the two outputs
|
|
// stay in lockstep.
|
|
|
|
/// Render a `CompareView` as a sequence of styled lines for the TUI.
|
|
/// Allocates all line text into `arena`; strings borrow from it.
|
|
pub fn renderCompareLines(
|
|
arena: std.mem.Allocator,
|
|
th: theme.Theme,
|
|
cv: compare_view.CompareView,
|
|
) ![]const StyledLine {
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
// Spacer matching the rest of the tab for layout consistency
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Header ──
|
|
var then_buf: [10]u8 = undefined;
|
|
var now_buf: [10]u8 = undefined;
|
|
const then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??";
|
|
const now_iso = compare_view.nowLabel(cv, &now_buf);
|
|
|
|
// Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)")
|
|
// when the caller supplied them; fall back to plain ISO dates.
|
|
const then_str = cv.then_label orelse then_iso;
|
|
const now_str = cv.now_label orelse now_iso;
|
|
|
|
const header = try std.fmt.allocPrint(
|
|
arena,
|
|
" Portfolio comparison: {s} → {s} ({d} day{s})",
|
|
.{ then_str, now_str, cv.days_between, compare_view.dayPlural(cv.days_between) },
|
|
);
|
|
try lines.append(arena, .{ .text = header, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Totals line ──
|
|
{
|
|
var t_then: [24]u8 = undefined;
|
|
var t_now: [24]u8 = undefined;
|
|
var t_delta: [32]u8 = undefined;
|
|
var t_pct: [16]u8 = undefined;
|
|
const t = compare_view.buildTotalsCells(cv.liquid, &t_then, &t_now, &t_delta, &t_pct);
|
|
|
|
const totals_text = try std.fmt.allocPrint(
|
|
arena,
|
|
" Liquid: {s}{s}{s} {s} {s}",
|
|
.{ t.then, compare_view.arrow, t.now, t.delta, t.pct },
|
|
);
|
|
try lines.append(arena, .{ .text = totals_text, .style = th.styleFor(t.style) });
|
|
}
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// ── Per-symbol table ──
|
|
if (cv.held_count == 0) {
|
|
try lines.append(arena, .{
|
|
.text = " No symbols held throughout this period.",
|
|
.style = th.mutedStyle(),
|
|
});
|
|
} else {
|
|
const subtitle = try std.fmt.allocPrint(
|
|
arena,
|
|
" Per-symbol price change ({d} held throughout)",
|
|
.{cv.held_count},
|
|
);
|
|
try lines.append(arena, .{ .text = subtitle, .style = th.mutedStyle() });
|
|
|
|
for (cv.symbols) |s| {
|
|
var p_then: [24]u8 = undefined;
|
|
var p_now: [24]u8 = undefined;
|
|
var p_pct: [16]u8 = undefined;
|
|
var p_dollar: [32]u8 = undefined;
|
|
const c = compare_view.buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);
|
|
|
|
// Single-color per row using the shared row template.
|
|
const row_text = try std.fmt.allocPrint(
|
|
arena,
|
|
" " ++ compare_view.symbol_row_fmt,
|
|
.{ c.symbol, c.price_then, compare_view.arrow, c.price_now, c.pct, c.dollar },
|
|
);
|
|
try lines.append(arena, .{ .text = row_text, .style = th.styleFor(c.style) });
|
|
}
|
|
}
|
|
|
|
// ── Hidden count ──
|
|
if (cv.added_count > 0 or cv.removed_count > 0) {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const hidden_text = try std.fmt.allocPrint(
|
|
arena,
|
|
" ({d} added, {d} removed since {s} — hidden)",
|
|
.{ cv.added_count, cv.removed_count, then_str },
|
|
);
|
|
try lines.append(arena, .{ .text = hidden_text, .style = th.mutedStyle() });
|
|
}
|
|
|
|
// ── Footer hint ──
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{
|
|
.text = " Esc or 'c' to return to timeline",
|
|
.style = th.mutedStyle(),
|
|
});
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
const Date = zfin.Date;
|
|
const snapshot = @import("../models/snapshot.zig");
|
|
|
|
test "renderHistoryLines: no series shows no-data message" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const lines = try renderHistoryLines(a, th, null, .liquid, null);
|
|
var saw_no_data = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "No history snapshots") != null) saw_no_data = true;
|
|
}
|
|
try testing.expect(saw_no_data);
|
|
}
|
|
|
|
test "renderHistoryLines: renders windows + chart + table in correct order" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 2);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 14),
|
|
.net_worth = 1000,
|
|
.liquid = 700,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[1] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.net_worth = 1100,
|
|
.liquid = 800,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
|
|
const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, .daily);
|
|
|
|
var windows_idx: ?usize = null;
|
|
var chart_idx: ?usize = null;
|
|
var table_idx: ?usize = null;
|
|
for (lines, 0..) |l, i| {
|
|
if (std.mem.eql(u8, std.mem.trim(u8, l.text, " "), "Change")) windows_idx = i;
|
|
if (std.mem.indexOf(u8, l.text, "Chart: Liquid") != null) chart_idx = i;
|
|
if (std.mem.indexOf(u8, l.text, "Recent snapshots") != null) table_idx = i;
|
|
}
|
|
try testing.expect(windows_idx != null);
|
|
try testing.expect(chart_idx != null);
|
|
try testing.expect(table_idx != null);
|
|
try testing.expect(windows_idx.? < chart_idx.?);
|
|
try testing.expect(chart_idx.? < table_idx.?);
|
|
}
|
|
|
|
test "renderHistoryLines: windows block includes 1 day + All-time" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 2);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 14),
|
|
.net_worth = 1000,
|
|
.liquid = 700,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[1] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.net_worth = 1100,
|
|
.liquid = 800,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
|
|
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, null);
|
|
|
|
var saw_1d = false;
|
|
var saw_all_time = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "1 day") != null) saw_1d = true;
|
|
if (std.mem.indexOf(u8, l.text, "All-time") != null) saw_all_time = true;
|
|
}
|
|
try testing.expect(saw_1d);
|
|
try testing.expect(saw_all_time);
|
|
}
|
|
|
|
test "renderHistoryLines: table rows emitted newest-first and column order is Liquid → Illiquid → NW" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 3);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 13),
|
|
.net_worth = 900,
|
|
.liquid = 600,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[1] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 14),
|
|
.net_worth = 1000,
|
|
.liquid = 700,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[2] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.net_worth = 1100,
|
|
.liquid = 800,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
|
|
|
|
const lines = try renderHistoryLines(a, th, series, .liquid, .daily);
|
|
|
|
var joined: std.ArrayList(u8) = .empty;
|
|
for (lines) |l| {
|
|
try joined.appendSlice(a, l.text);
|
|
try joined.append(a, '\n');
|
|
}
|
|
const text = joined.items;
|
|
|
|
const h_liq = std.mem.indexOf(u8, text, "Liquid") orelse return error.TestExpectedMatch;
|
|
const h_ill = std.mem.indexOf(u8, text, "Illiquid") orelse return error.TestExpectedMatch;
|
|
const h_nw = std.mem.indexOf(u8, text, "Net Worth") orelse return error.TestExpectedMatch;
|
|
try testing.expect(h_liq < h_ill);
|
|
try testing.expect(h_ill < h_nw);
|
|
|
|
const d_new = std.mem.lastIndexOf(u8, text, "2024-03-15") orelse return error.TestExpectedMatch;
|
|
const d_old = std.mem.lastIndexOf(u8, text, "2024-03-13") orelse return error.TestExpectedMatch;
|
|
try testing.expect(d_new < d_old);
|
|
}
|
|
|
|
test "renderHistoryLines: metric cycling changes chart label and windows header" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 1);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.net_worth = 100,
|
|
.liquid = 60,
|
|
.illiquid = 40,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
|
|
|
|
const lines_ill = try renderHistoryLines(a, th, series, .illiquid, null);
|
|
var saw_ill_chart = false;
|
|
for (lines_ill) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Chart: Illiquid") != null) saw_ill_chart = true;
|
|
}
|
|
try testing.expect(saw_ill_chart);
|
|
}
|
|
|
|
test "cycleMetric: liquid → illiquid → net_worth → liquid" {
|
|
var m: timeline.Metric = .liquid;
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.illiquid, m);
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.net_worth, m);
|
|
m = switch (m) {
|
|
.liquid => .illiquid,
|
|
.illiquid => .net_worth,
|
|
.net_worth => .liquid,
|
|
};
|
|
try testing.expectEqual(timeline.Metric.liquid, m);
|
|
}
|
|
|
|
test "renderHistoryLinesFull: cursor highlights selected row" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const pts = try a.alloc(timeline.TimelinePoint, 3);
|
|
pts[0] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 13),
|
|
.net_worth = 900,
|
|
.liquid = 600,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[1] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 14),
|
|
.net_worth = 1000,
|
|
.liquid = 700,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
pts[2] = .{
|
|
.as_of_date = Date.fromYmd(2024, 3, 15),
|
|
.net_worth = 1100,
|
|
.liquid = 800,
|
|
.illiquid = 300,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
};
|
|
const series: timeline.TimelineSeries = .{ .points = pts, .allocator = a };
|
|
const rows = try rowsFromSeries(a, series, .daily);
|
|
|
|
// Two rows selected (idx 0 and 2), cursor on idx 1.
|
|
const result = try renderHistoryLinesFull(
|
|
a,
|
|
th,
|
|
series,
|
|
.liquid,
|
|
.daily,
|
|
rows,
|
|
1,
|
|
.{ 0, 2 },
|
|
);
|
|
|
|
try testing.expectEqual(@as(usize, 3), result.table_row_count);
|
|
try testing.expect(result.table_first_line > 0);
|
|
|
|
// Check that the first row (selected, not cursor) has the marker
|
|
// character in its text at the expected position.
|
|
const first_row_line = result.lines[result.table_first_line];
|
|
try testing.expect(std.mem.indexOf(u8, first_row_line.text, "* ") != null);
|
|
|
|
// Third row is selected too.
|
|
const third_row_line = result.lines[result.table_first_line + 2];
|
|
try testing.expect(std.mem.indexOf(u8, third_row_line.text, "* ") != null);
|
|
|
|
// Second row (cursor) has no marker — just the cursor style.
|
|
const second_row_line = result.lines[result.table_first_line + 1];
|
|
try testing.expect(std.mem.indexOf(u8, second_row_line.text, "* ") == null);
|
|
}
|
|
|
|
// ── renderCompareLines tests ──────────────────────────────────
|
|
|
|
test "renderCompareLines: emits header, totals, symbols, hidden-count, footer" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const symbols = [_]compare_view.SymbolChange{
|
|
.{
|
|
.symbol = "FOO",
|
|
.price_then = 100,
|
|
.price_now = 110,
|
|
.shares_held_throughout = 10,
|
|
.pct_change = 0.10,
|
|
.dollar_change = 100,
|
|
.style = .positive,
|
|
},
|
|
};
|
|
const cv = compare_view.CompareView{
|
|
.then_date = Date.fromYmd(2024, 1, 15),
|
|
.now_date = Date.fromYmd(2024, 3, 15),
|
|
.days_between = 60,
|
|
.now_is_live = false,
|
|
.liquid = compare_view.buildTotalsRow(10_000, 10_500),
|
|
.symbols = @constCast(&symbols),
|
|
.held_count = 1,
|
|
.added_count = 1,
|
|
.removed_count = 0,
|
|
};
|
|
|
|
const lines = try renderCompareLines(a, th, cv);
|
|
|
|
var saw_header = false;
|
|
var saw_totals = false;
|
|
var saw_row = false;
|
|
var saw_hidden = false;
|
|
var saw_footer = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Portfolio comparison: 2024-01-15 → 2024-03-15") != null) saw_header = true;
|
|
if (std.mem.indexOf(u8, l.text, "Liquid:") != null and std.mem.indexOf(u8, l.text, "$10,000.00") != null) saw_totals = true;
|
|
if (std.mem.indexOf(u8, l.text, "FOO") != null and std.mem.indexOf(u8, l.text, "+10.00%") != null) saw_row = true;
|
|
if (std.mem.indexOf(u8, l.text, "1 added, 0 removed since 2024-01-15") != null) saw_hidden = true;
|
|
if (std.mem.indexOf(u8, l.text, "Esc") != null) saw_footer = true;
|
|
}
|
|
try testing.expect(saw_header);
|
|
try testing.expect(saw_totals);
|
|
try testing.expect(saw_row);
|
|
try testing.expect(saw_hidden);
|
|
try testing.expect(saw_footer);
|
|
}
|
|
|
|
test "renderCompareLines: live-now shows 'today' not a date" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const cv = compare_view.CompareView{
|
|
.then_date = Date.fromYmd(2024, 1, 15),
|
|
.now_date = Date.fromYmd(2024, 3, 15),
|
|
.days_between = 60,
|
|
.now_is_live = true,
|
|
.liquid = compare_view.buildTotalsRow(100, 105),
|
|
.symbols = &.{},
|
|
.held_count = 0,
|
|
.added_count = 0,
|
|
.removed_count = 0,
|
|
};
|
|
const lines = try renderCompareLines(a, th, cv);
|
|
|
|
var saw_today = false;
|
|
var saw_no_symbols = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "→ today") != null) saw_today = true;
|
|
if (std.mem.indexOf(u8, l.text, "No symbols held throughout") != null) saw_no_symbols = true;
|
|
}
|
|
try testing.expect(saw_today);
|
|
try testing.expect(saw_no_symbols);
|
|
}
|
|
|
|
test "renderCompareLines: no hidden line when no add/remove" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const cv = compare_view.CompareView{
|
|
.then_date = Date.fromYmd(2024, 1, 15),
|
|
.now_date = Date.fromYmd(2024, 3, 15),
|
|
.days_between = 60,
|
|
.now_is_live = false,
|
|
.liquid = compare_view.buildTotalsRow(100, 100),
|
|
.symbols = &.{},
|
|
.held_count = 0,
|
|
.added_count = 0,
|
|
.removed_count = 0,
|
|
};
|
|
const lines = try renderCompareLines(a, th, cv);
|
|
|
|
for (lines) |l| {
|
|
try testing.expect(std.mem.indexOf(u8, l.text, "hidden") == null);
|
|
}
|
|
}
|
|
|
|
// Keep refAllDeclsRecursive happy
|
|
test {
|
|
_ = snapshot;
|
|
}
|
|
|
|
test "renderCompareLines: bucket labels override ISO dates in header" {
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
const a = arena.allocator();
|
|
const th = theme.default_theme;
|
|
|
|
const cv = compare_view.CompareView{
|
|
.then_date = Date.fromYmd(2025, 3, 28),
|
|
.now_date = Date.fromYmd(2026, 5, 8),
|
|
.days_between = 406,
|
|
.now_is_live = false,
|
|
.liquid = compare_view.buildTotalsRow(6_000_000, 8_000_000),
|
|
.symbols = &.{},
|
|
.held_count = 0,
|
|
.added_count = 0,
|
|
.removed_count = 0,
|
|
.then_label = "Q1 2025 (ended 2025-03-28)",
|
|
.now_label = null,
|
|
};
|
|
const lines = try renderCompareLines(a, th, cv);
|
|
|
|
var found_header = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Q1 2025 (ended 2025-03-28)") != null and
|
|
std.mem.indexOf(u8, l.text, "→ 2026-05-08") != null)
|
|
{
|
|
found_header = true;
|
|
break;
|
|
}
|
|
}
|
|
try testing.expect(found_header);
|
|
}
|
|
|
|
test "priorPointBefore: returns null for the very first data point" {
|
|
const points = [_]timeline.TimelinePoint{
|
|
.{
|
|
.as_of_date = Date.fromYmd(2014, 7, 3),
|
|
.net_worth = 1_280_000,
|
|
.liquid = 1_280_000,
|
|
.illiquid = 0,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
.source = .imported,
|
|
},
|
|
.{
|
|
.as_of_date = Date.fromYmd(2015, 7, 3),
|
|
.net_worth = 1_500_000,
|
|
.liquid = 1_500_000,
|
|
.illiquid = 0,
|
|
.accounts = &.{},
|
|
.tax_types = &.{},
|
|
.source = .imported,
|
|
},
|
|
};
|
|
// Target preceding all data → null.
|
|
try testing.expectEqual(@as(?timeline.TimelinePoint, null), priorPointBefore(&points, Date.fromYmd(2014, 1, 1)));
|
|
// Target after first → finds the first point.
|
|
const got = priorPointBefore(&points, Date.fromYmd(2015, 1, 1));
|
|
try testing.expect(got != null);
|
|
try testing.expect(got.?.as_of_date.eql(Date.fromYmd(2014, 7, 3)));
|
|
// Target after both → finds the latest.
|
|
const got2 = priorPointBefore(&points, Date.fromYmd(2026, 1, 1));
|
|
try testing.expect(got2 != null);
|
|
try testing.expect(got2.?.as_of_date.eql(Date.fromYmd(2015, 7, 3)));
|
|
}
|