2645 lines
102 KiB
Zig
2645 lines
102 KiB
Zig
//! `review` TUI tab — per-holding performance and risk dashboard.
|
||
//!
|
||
//! The TUI surface for the `review` view, rendered as a wide line-list
|
||
//! table. Mirrors the portfolio tab's sort-key conventions (`>` next
|
||
//! column, `<` previous column, `o` reverse direction) so muscle memory
|
||
//! transfers between the two tabs.
|
||
//!
|
||
//! State is small: cursor/scroll offsets aren't tracked here (the App-
|
||
//! level scroll handling does it), and the `view` itself is rebuilt on
|
||
//! activate/reload from the per-portfolio shared cache.
|
||
//!
|
||
//! All data sources are App-scoped (`app.portfolio.{summary, file,
|
||
//! account_map}`, `app.svc` cached candles/dividends), so the tab's
|
||
//! lifecycle is straightforward — load on activate, free on deinit.
|
||
|
||
const std = @import("std");
|
||
const vaxis = @import("vaxis");
|
||
const zfin = @import("../root.zig");
|
||
const Money = @import("../Money.zig");
|
||
const fmt = @import("../format.zig");
|
||
const theme = @import("theme.zig");
|
||
const tui = @import("../tui.zig");
|
||
const framework = @import("tab_framework.zig");
|
||
const review_view = @import("../views/review.zig");
|
||
const observations_view = @import("../views/observations_view.zig");
|
||
const observations = @import("../analytics/observations.zig");
|
||
const Journal = @import("../data/Journal.zig");
|
||
const input_buffer = @import("input_buffer.zig");
|
||
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
|
||
|
||
const App = tui.App;
|
||
const StyledLine = tui.StyledLine;
|
||
const StyleSpan = tui.StyleSpan;
|
||
|
||
// ── Tab-local action enum ─────────────────────────────────────
|
||
|
||
pub const Action = enum {
|
||
/// Move the active sort field to the next column (right).
|
||
sort_col_next,
|
||
/// Move the active sort field to the previous column (left).
|
||
sort_col_prev,
|
||
/// Flip the current sort direction (asc ↔ desc).
|
||
sort_reverse,
|
||
/// Move keyboard focus to the holdings table.
|
||
focus_holdings,
|
||
/// Move keyboard focus to the findings table.
|
||
focus_findings,
|
||
/// Toggle expansion of the cursor-selected finding (findings
|
||
/// table only). No-op if focus is the holdings table.
|
||
toggle_expand,
|
||
/// Acknowledge the cursor-selected finding (findings table
|
||
/// only). Appends an `acknowledged` entry to the journal and
|
||
/// rebuilds the findings view. No-op if focus is the holdings
|
||
/// table.
|
||
ack,
|
||
/// Toggle whether already-acknowledged findings are shown in
|
||
/// the findings table.
|
||
toggle_show_acked,
|
||
};
|
||
|
||
/// Which table currently receives cursor-movement keystrokes.
|
||
pub const FocusTarget = enum {
|
||
holdings,
|
||
findings,
|
||
};
|
||
|
||
/// Modal sub-state. `.normal` lets keystrokes flow through the
|
||
/// global keymap (sort, scroll, tab nav). `.ack_note` puts the tab
|
||
/// into note-input mode: the inline input bar swallows keys via
|
||
/// `handleKeyMulti`, Esc cancels, Ctrl+Enter commits the
|
||
/// accumulated
|
||
/// fragments to a new journal entry.
|
||
pub const InputMode = enum {
|
||
normal,
|
||
ack_note,
|
||
};
|
||
|
||
// ── Tab-private state ─────────────────────────────────────────
|
||
|
||
pub const State = struct {
|
||
/// Whether `loadData` has populated `view` for the currently
|
||
/// loaded portfolio. Cleared by `reload` to force re-build.
|
||
loaded: bool = false,
|
||
/// Computed view. Owned by State; freed in `deinit` and `reload`.
|
||
view: ?review_view.ReviewView = null,
|
||
/// Per-portfolio classification metadata (`metadata.srf`). Loaded
|
||
/// lazily on first activation, kept across reloads (cheap and
|
||
/// rarely-changing). Freed in `deinit`.
|
||
classification_map: ?zfin.classification.ClassificationMap = null,
|
||
/// Active sort field. Default `.sector` (asc) provides the
|
||
/// "grouped by sector with symbol-asc tiebreaker" entry state
|
||
/// — see `views/review.sortRows` for why the sector column
|
||
/// bakes in the symbol-asc pre-pass.
|
||
sort_field: review_view.SortField = .sector,
|
||
sort_dir: review_view.SortDirection = .asc,
|
||
/// Content-row index of the column-header line, written by
|
||
/// `buildStyledLines` after appending the header. Used by
|
||
/// `handleMouse` to detect clicks on the header (column-sort
|
||
/// hit-test) vs. clicks on data rows.
|
||
header_row: usize = 0,
|
||
/// Content-row index of the first holdings data row (right
|
||
/// after the header). Used by `handleMouse` to translate clicks
|
||
/// into a holdings_cursor index.
|
||
holdings_first_row: usize = 0,
|
||
/// Content-row index of the first findings data row (right
|
||
/// after the "Findings (...)" heading + separator). Zero when
|
||
/// the findings section isn't rendered. Used by `handleMouse`
|
||
/// to translate clicks into a findings_cursor index.
|
||
findings_first_row: usize = 0,
|
||
/// Content-row count of the findings table (including expansion
|
||
/// lines). Used by `handleMouse` to bound clicks. Zero when no
|
||
/// findings section is rendered.
|
||
findings_row_count: usize = 0,
|
||
|
||
// ── Observations & journal (M2) ──────────────────────────
|
||
|
||
/// User's acknowledgment journal (`acknowledgments.srf`). Loaded
|
||
/// once on first activation; mutated by `ack` actions and
|
||
/// re-saved atomically. Freed in `deinit`.
|
||
journal: ?Journal = null,
|
||
/// Joined view of `view.observations` + `journal`, rebuilt on
|
||
/// every reload + ack. Owned by State; freed in `deinit` and
|
||
/// `reload`.
|
||
findings_view: ?observations_view.FindingsView = null,
|
||
/// Whether already-acknowledged findings are rendered. Toggled
|
||
/// by `toggle_show_acked` (`v` key by default).
|
||
show_acked: bool = false,
|
||
|
||
// ── Two-cursor focus model ───────────────────────────────
|
||
|
||
/// Which table currently receives cursor-movement keystrokes.
|
||
focus: FocusTarget = .holdings,
|
||
/// Holdings-table cursor (row index into `view.rows`). Persists
|
||
/// across focus toggles so jumping between tables doesn't lose
|
||
/// place.
|
||
holdings_cursor: usize = 0,
|
||
/// Findings-table cursor (row index into `findings_view.rows`).
|
||
/// Persists across focus toggles.
|
||
findings_cursor: usize = 0,
|
||
/// Currently expanded finding-row index (or null when none is
|
||
/// expanded). The expansion is rendered inline in the findings
|
||
/// table; only one finding can be expanded at a time.
|
||
expanded_finding: ?usize = null,
|
||
|
||
// ── Inline note-input state (M2 step 8b) ──────────────────
|
||
|
||
/// Modal sub-state of the tab. Drives `handleKey` dispatch:
|
||
/// `.normal` lets keystrokes flow through the global keymap;
|
||
/// `.ack_note` swallows them via `handleKeyMulti` to build up
|
||
/// the user's reasoning before Ctrl+Enter commits the ack.
|
||
input_mode: InputMode = .normal,
|
||
/// Multi-fragment input buffer for the current ack note.
|
||
/// Populated by `handleKeyMulti`; flushed on Enter (one
|
||
/// fragment per Enter) or Ctrl+Enter (commits the ack).
|
||
// SAFETY: only read up to `note_len` bytes. `note_len` starts
|
||
// at 0 (no reads), and `handleKeyMulti` only writes a byte
|
||
// before incrementing `note_len`.
|
||
note_buf: [512]u8 = undefined,
|
||
note_len: usize = 0,
|
||
/// Fragments of the in-progress ack note. Each is a heap-owned
|
||
/// slice. Freed and cleared on commit / cancel.
|
||
note_fragments: std.ArrayList([]u8) = .empty,
|
||
};
|
||
|
||
// ── Tab framework contract ────────────────────────────────────
|
||
|
||
pub const meta: framework.TabMeta(Action) = .{
|
||
.label = "Review",
|
||
.default_bindings = &.{
|
||
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
|
||
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
|
||
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
|
||
// `[` and `]` flip focus between the holdings and findings
|
||
// tables. Single-key alternatives (`h`/`l`, `Tab`) collide
|
||
// with global tab navigation; brackets are unused and
|
||
// visually evoke "swap left/right pane".
|
||
.{ .action = .focus_holdings, .key = .{ .codepoint = '[' } },
|
||
.{ .action = .focus_findings, .key = .{ .codepoint = ']' } },
|
||
.{ .action = .toggle_expand, .key = .{ .codepoint = vaxis.Key.enter } },
|
||
.{ .action = .ack, .key = .{ .codepoint = 'a' } },
|
||
.{ .action = .toggle_show_acked, .key = .{ .codepoint = 'v' } },
|
||
},
|
||
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||
.sort_col_next = "Sort: next column",
|
||
.sort_col_prev = "Sort: previous column",
|
||
.sort_reverse = "Sort: reverse direction",
|
||
.focus_holdings = "Focus: holdings table",
|
||
.focus_findings = "Focus: findings table",
|
||
.toggle_expand = "Expand/collapse finding",
|
||
.ack = "Acknowledge finding",
|
||
.toggle_show_acked = "Toggle show acked findings",
|
||
}),
|
||
.status_hints = &.{
|
||
.focus_holdings,
|
||
.focus_findings,
|
||
.ack,
|
||
.toggle_show_acked,
|
||
.sort_col_prev,
|
||
.sort_col_next,
|
||
},
|
||
};
|
||
|
||
/// Sort fields cycled through by `sort_col_next` / `sort_col_prev`,
|
||
/// in column-display order. The "default grouping" (null) is the
|
||
/// entry state and is reachable by cycling past the end of the array.
|
||
/// Sort fields cycled through by `sort_col_next` / `sort_col_prev`,
|
||
/// in column-display order (matches `col_order` below). Tax is the
|
||
/// last column visually, so it's the last cycle slot too — `<>` then
|
||
/// walks left-to-right across the visible columns the way the user
|
||
/// expects.
|
||
const sortable_fields = [_]review_view.SortField{
|
||
.symbol, .sector, .weight, .return_1y,
|
||
.return_3y, .return_5y, .return_10y, .vol_3y,
|
||
.vol_10y, .sharpe_3y, .sharpe_10y, .maxdd_5y,
|
||
.tax_pct,
|
||
};
|
||
|
||
pub const tab = struct {
|
||
pub const ActionT = Action;
|
||
pub const StateT = State;
|
||
|
||
pub fn init(state: *State, app: *App) !void {
|
||
_ = app;
|
||
state.* = .{};
|
||
}
|
||
|
||
pub fn deinit(state: *State, app: *App) void {
|
||
deinitState(state, app.allocator);
|
||
}
|
||
|
||
pub fn activate(state: *State, app: *App) !void {
|
||
if (tab.isDisabled(app)) return;
|
||
if (state.loaded) return;
|
||
// Load the journal before building findings; `loadData`
|
||
// joins both into `findings_view`.
|
||
if (state.journal == null) loadJournal(state, app);
|
||
loadData(state, app);
|
||
}
|
||
|
||
pub const deactivate = framework.noopDeactivate(State);
|
||
|
||
/// Manual refresh: drops the cached view and re-builds. Also
|
||
/// clears the dividend cache (in case new dividends arrived) and
|
||
/// the shared `account_map` so accounts.srf gets re-read. The
|
||
/// classification_map persists — it's per-portfolio, not
|
||
/// per-refresh. The journal is reloaded too in case the user
|
||
/// edited acknowledgments.srf out-of-band.
|
||
pub fn reload(state: *State, app: *App) !void {
|
||
if (state.view) |*v| v.deinit(app.allocator);
|
||
state.view = null;
|
||
if (state.findings_view) |*fv| fv.deinit(app.allocator);
|
||
state.findings_view = null;
|
||
state.loaded = false;
|
||
// Drop the cached account_map so the worker re-reads
|
||
// accounts.srf on the next accountMap() call.
|
||
app.portfolio.invalidateAccountMap();
|
||
loadJournal(state, app);
|
||
loadData(state, app);
|
||
}
|
||
|
||
pub const tick = framework.noopTick(State);
|
||
|
||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||
switch (action) {
|
||
.sort_col_next => {
|
||
state.sort_field = nextSortField(state.sort_field);
|
||
applySort(state);
|
||
},
|
||
.sort_col_prev => {
|
||
state.sort_field = prevSortField(state.sort_field);
|
||
applySort(state);
|
||
},
|
||
.sort_reverse => {
|
||
state.sort_dir = if (state.sort_dir == .asc) .desc else .asc;
|
||
applySort(state);
|
||
},
|
||
.focus_holdings => {
|
||
state.focus = .holdings;
|
||
state.expanded_finding = null;
|
||
},
|
||
.focus_findings => {
|
||
state.focus = .findings;
|
||
},
|
||
.toggle_expand => {
|
||
if (state.focus != .findings) return;
|
||
const fv = state.findings_view orelse return;
|
||
if (fv.rows.len == 0) return;
|
||
if (state.expanded_finding == state.findings_cursor) {
|
||
state.expanded_finding = null;
|
||
} else {
|
||
state.expanded_finding = state.findings_cursor;
|
||
}
|
||
},
|
||
.ack => ackCurrentFinding(state, app),
|
||
.toggle_show_acked => {
|
||
state.show_acked = !state.show_acked;
|
||
rebuildFindingsView(state, app);
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Review requires a loaded portfolio file and per-symbol
|
||
/// allocations. Same gate as the analysis tab.
|
||
pub fn isDisabled(app: *App) bool {
|
||
return app.portfolio.file == null;
|
||
}
|
||
|
||
/// Drop cached findings on portfolio reload.
|
||
///
|
||
/// `state.view` and `state.findings_view` hold pointers into
|
||
/// the previous portfolio's memory (positions / allocation
|
||
/// symbols), so they MUST be invalidated before the
|
||
/// underlying data is freed. Cursors / expansion state get
|
||
/// reset because they're row indices into the stale
|
||
/// findings_view; preserving them risks pointing past the
|
||
/// end of the rebuilt list.
|
||
///
|
||
/// The journal is also reloaded — the user may have hand-
|
||
/// edited `acknowledgments.srf` while the TUI was running.
|
||
/// (Same rationale as the user-requested `reload` action.)
|
||
///
|
||
/// Eagerly recomputes only when this tab is active —
|
||
/// otherwise next `activate` lazy-loads.
|
||
pub fn onPortfolioReload(state: *State, app: *App) void {
|
||
if (state.view) |*v| v.deinit(app.allocator);
|
||
state.view = null;
|
||
if (state.findings_view) |*fv| fv.deinit(app.allocator);
|
||
state.findings_view = null;
|
||
if (state.journal) |*j| j.deinit();
|
||
state.journal = null;
|
||
state.loaded = false;
|
||
state.holdings_cursor = 0;
|
||
state.findings_cursor = 0;
|
||
state.expanded_finding = null;
|
||
if (app.active_tab == .review) {
|
||
tab.activate(state, app) catch |err| std.log.debug("review activate failed: {t}", .{err});
|
||
}
|
||
}
|
||
|
||
/// Mouse handling: left-click on the column-header row sorts
|
||
/// by that column. Re-clicking the active column flips
|
||
/// direction; clicking a different column resets to its
|
||
/// `defaultDir` (asc for symbol/sector, desc for numeric
|
||
/// columns — best/worst-first matches the typical "show me
|
||
/// the leaders" reading).
|
||
///
|
||
/// Click on a holdings data row → focus holdings + move
|
||
/// cursor to that row. Click on a findings data row → focus
|
||
/// findings + move cursor to that finding's index.
|
||
///
|
||
/// Wheel events fall through to App's scroll handling via
|
||
/// `onWheelMove` returning false (see below).
|
||
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
|
||
if (mouse.button != .left) return false;
|
||
if (mouse.type != .press) return false;
|
||
const view = state.view orelse return false;
|
||
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
||
|
||
// Header row click ⇒ column-sort hit-test.
|
||
if (content_row == state.header_row) {
|
||
const click_col: usize = @intCast(mouse.col);
|
||
if (!applyHeaderClick(state, click_col)) return false;
|
||
applySort(state);
|
||
return true;
|
||
}
|
||
|
||
// Holdings data-row click.
|
||
if (content_row >= state.holdings_first_row and
|
||
content_row < state.holdings_first_row + view.rows.len)
|
||
{
|
||
state.focus = .holdings;
|
||
state.holdings_cursor = content_row - state.holdings_first_row;
|
||
return true;
|
||
}
|
||
|
||
// Findings data-row click. Note: with expansion lines mixed
|
||
// in, a click on an expansion line falls into this range
|
||
// too — we treat that as "focus findings, leave cursor
|
||
// alone" so the user doesn't get teleported when they
|
||
// click a note line by accident.
|
||
if (state.findings_row_count > 0 and
|
||
content_row >= state.findings_first_row and
|
||
content_row < state.findings_first_row + state.findings_row_count)
|
||
{
|
||
state.focus = .findings;
|
||
const fv = state.findings_view orelse return true;
|
||
// Walk forward from the first finding row, counting
|
||
// logical findings until we either hit content_row or
|
||
// exhaust the rows. This handles inline expansion: the
|
||
// expansion lines belong to the finding above them.
|
||
var visual_row = state.findings_first_row;
|
||
for (fv.rows, 0..) |_, i| {
|
||
const expansion = if (state.expanded_finding == i)
|
||
expansionLineCount(fv.rows[i]) + (if (state.expanded_finding == i) inputBarLineCount(state) else 0)
|
||
else
|
||
0;
|
||
const next_visual_row = visual_row + 1 + expansion;
|
||
if (content_row >= visual_row and content_row < next_visual_row) {
|
||
state.findings_cursor = i;
|
||
// Clicking a different row collapses any prior
|
||
// expansion. Mirrors `onCursorMove`'s "single
|
||
// expansion at a time" invariant; without this,
|
||
// a stale `expanded_finding` keeps rendering
|
||
// detail/note lines for the *old* row index,
|
||
// which now points at a different finding after
|
||
// any rebuild that shifted indices.
|
||
if (state.expanded_finding) |exp| {
|
||
if (exp != i) state.expanded_finding = null;
|
||
}
|
||
return true;
|
||
}
|
||
visual_row = next_visual_row;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// j/k/up/down route to the focused table's cursor. Returns
|
||
/// true on success so the framework consumes the event;
|
||
/// returns false when there's no row to move to (empty table)
|
||
/// so the framework falls through to viewport scroll.
|
||
pub fn onCursorMove(state: *State, app: *App, delta: isize) bool {
|
||
_ = app;
|
||
switch (state.focus) {
|
||
.holdings => {
|
||
const view = state.view orelse return false;
|
||
if (view.rows.len == 0) return false;
|
||
state.holdings_cursor = clampCursor(state.holdings_cursor, delta, view.rows.len);
|
||
return true;
|
||
},
|
||
.findings => {
|
||
const fv = state.findings_view orelse return false;
|
||
if (fv.rows.len == 0) return false;
|
||
state.findings_cursor = clampCursor(state.findings_cursor, delta, fv.rows.len);
|
||
// Collapsing the expansion when the cursor moves
|
||
// off the previously-expanded row keeps the
|
||
// single-expansion invariant. Users can re-expand
|
||
// with Enter.
|
||
if (state.expanded_finding) |exp| {
|
||
if (exp != state.findings_cursor) state.expanded_finding = null;
|
||
}
|
||
return true;
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Wheel events scroll the viewport, never the cursor. Returns
|
||
/// false unconditionally so the framework falls through to its
|
||
/// scroll-by-delta path. Required for the multi-cursor review
|
||
/// tab; see framework docs.
|
||
pub fn onWheelMove(state: *State, app: *App, delta: isize) bool {
|
||
_ = state;
|
||
_ = app;
|
||
_ = delta;
|
||
return false;
|
||
}
|
||
|
||
/// Tab-local key handler. Runs before the global keymap so we
|
||
/// can swallow keystrokes when the inline note-input bar is
|
||
/// open. Returns true on consume; false to fall through to
|
||
/// normal action dispatch.
|
||
pub fn handleKey(state: *State, app: *App, key: vaxis.Key) bool {
|
||
if (state.input_mode != .ack_note) return false;
|
||
|
||
switch (input_buffer.handleKeyMulti(&state.note_buf, &state.note_len, key)) {
|
||
.cancelled => {
|
||
cancelAckNote(state, app);
|
||
return true;
|
||
},
|
||
.fragment => {
|
||
// Capture the fragment data BEFORE resetting len.
|
||
const dup = app.allocator.dupe(u8, state.note_buf[0..state.note_len]) catch |err| {
|
||
app.setStatus("Note buffer alloc failed");
|
||
std.log.scoped(.review_tab).warn("frag dup failed: {s}", .{@errorName(err)});
|
||
return true;
|
||
};
|
||
state.note_fragments.append(app.allocator, dup) catch |err| {
|
||
app.allocator.free(dup);
|
||
app.setStatus("Note buffer alloc failed");
|
||
std.log.scoped(.review_tab).warn("frag append failed: {s}", .{@errorName(err)});
|
||
return true;
|
||
};
|
||
state.note_len = 0;
|
||
return true;
|
||
},
|
||
.committed => {
|
||
commitAckNote(state, app);
|
||
return true;
|
||
},
|
||
.edited, .ignored => return true, // swallow even ignored so global keys don't fire mid-input
|
||
}
|
||
}
|
||
};
|
||
|
||
/// Pure-state header-click handler: maps `click_col` to a column,
|
||
/// updates `state.sort_field` / `state.sort_dir` per the
|
||
/// "re-click flips, new column resets to defaultDir" rule, and
|
||
/// returns true when a sort change should be applied. False means
|
||
/// the click landed on a gap, the prefix, or past the rightmost
|
||
/// column — caller should not invoke `applySort`.
|
||
///
|
||
/// Extracted from `handleMouse` so the sort-mutation logic can be
|
||
/// unit-tested without an `*App`.
|
||
fn applyHeaderClick(state: *State, click_col: usize) bool {
|
||
const hit = hitTestHeader(click_col) orelse return false;
|
||
const sf = hit.sortField() orelse return false;
|
||
if (state.sort_field == sf) {
|
||
state.sort_dir = state.sort_dir.flip();
|
||
} else {
|
||
state.sort_field = sf;
|
||
state.sort_dir = hit.defaultDir();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── Sort cycling ──────────────────────────────────────────────
|
||
|
||
/// Cycle forward through `sortable_fields`, wrapping at the end.
|
||
fn nextSortField(curr: review_view.SortField) review_view.SortField {
|
||
for (sortable_fields, 0..) |f, i| {
|
||
if (f == curr) {
|
||
const next_idx = (i + 1) % sortable_fields.len;
|
||
return sortable_fields[next_idx];
|
||
}
|
||
}
|
||
return sortable_fields[0]; // shouldn't happen — recover gracefully
|
||
}
|
||
|
||
/// Cycle backward through `sortable_fields`, wrapping at the start.
|
||
fn prevSortField(curr: review_view.SortField) review_view.SortField {
|
||
for (sortable_fields, 0..) |f, i| {
|
||
if (f == curr) {
|
||
const prev_idx = if (i == 0) sortable_fields.len - 1 else i - 1;
|
||
return sortable_fields[prev_idx];
|
||
}
|
||
}
|
||
return sortable_fields[sortable_fields.len - 1];
|
||
}
|
||
|
||
fn applySort(state: *State) void {
|
||
const view = &(state.view orelse return);
|
||
review_view.sortRows(view.rows, state.sort_field, state.sort_dir);
|
||
}
|
||
|
||
/// Clamp `current + delta` into `[0, len-1]`. Used by `onCursorMove`
|
||
/// to safely translate j/k presses into a new cursor index. Caller
|
||
/// should have already checked `len > 0`.
|
||
fn clampCursor(current: usize, delta: isize, len: usize) usize {
|
||
std.debug.assert(len > 0);
|
||
const max_idx = len - 1;
|
||
if (delta < 0) {
|
||
const abs_delta: usize = @intCast(-delta);
|
||
return if (abs_delta >= current) 0 else current - abs_delta;
|
||
} else {
|
||
const abs_delta: usize = @intCast(delta);
|
||
const proposed = current +| abs_delta;
|
||
return @min(proposed, max_idx);
|
||
}
|
||
}
|
||
|
||
/// Number of *expansion* lines a single finding contributes when
|
||
/// expanded, in addition to the row itself. Used by `handleMouse`
|
||
/// to map clicks back to logical finding indices when expansion
|
||
/// lines are interleaved.
|
||
fn expansionLineCount(row: observations_view.FindingRow) usize {
|
||
// Layout matches `appendExpansionLines`: always one detail line.
|
||
// Plus one ack-date line + one note line per fragment when acked.
|
||
var count: usize = 1; // detail line
|
||
if (row.ack_entry) |entry| {
|
||
count += 1; // ack date line
|
||
count += entry.notes.len;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
/// Number of input-bar lines contributed when the inline note-input
|
||
/// modal is open. Layout matches `appendInputBarLines`: one line per
|
||
/// completed fragment + one in-progress line + one hint line.
|
||
fn inputBarLineCount(state: *const State) usize {
|
||
if (state.input_mode != .ack_note) return 0;
|
||
return state.note_fragments.items.len + 2;
|
||
}
|
||
|
||
/// Acknowledge the cursor-selected finding. Enters note-input
|
||
/// mode: the user types their reasoning (Enter completes a
|
||
/// fragment, Ctrl+Enter commits all fragments + writes the ack,
|
||
/// Esc cancels). Pressing Ctrl+Enter immediately produces an ack
|
||
/// with no notes — supported intentionally so users who don't
|
||
/// want to write reasoning can dismiss findings quickly.
|
||
fn ackCurrentFinding(state: *State, app: *App) void {
|
||
if (state.focus != .findings) {
|
||
app.setStatus("Switch to findings table (]) before acknowledging");
|
||
return;
|
||
}
|
||
const fv = state.findings_view orelse return;
|
||
if (fv.rows.len == 0) return;
|
||
if (state.findings_cursor >= fv.rows.len) return;
|
||
|
||
const row = fv.rows[state.findings_cursor];
|
||
if (row.is_acked) {
|
||
app.setStatus("Already acknowledged");
|
||
return;
|
||
}
|
||
|
||
// Auto-expand so the inline input bar has somewhere to render.
|
||
state.expanded_finding = state.findings_cursor;
|
||
state.input_mode = .ack_note;
|
||
state.note_len = 0;
|
||
// `note_fragments` should already be empty (we only enter
|
||
// input mode from `.normal`, and `commitAckNote`/`cancelAckNote`
|
||
// both clear it on exit). Defensive: clear anyway.
|
||
clearNoteFragments(state, app.allocator);
|
||
app.setStatus("Type reasoning. Enter = next line. Ctrl+Enter = save. Esc = cancel.");
|
||
}
|
||
|
||
/// Free `state.note_fragments` items + the slice. Idempotent.
|
||
fn clearNoteFragments(state: *State, allocator: std.mem.Allocator) void {
|
||
for (state.note_fragments.items) |frag| allocator.free(frag);
|
||
state.note_fragments.clearAndFree(allocator);
|
||
}
|
||
|
||
/// Cancel the in-progress ack note. Returns to `.normal` mode and
|
||
/// clears all input state.
|
||
fn cancelAckNote(state: *State, app: *App) void {
|
||
state.input_mode = .normal;
|
||
state.note_len = 0;
|
||
clearNoteFragments(state, app.allocator);
|
||
// No setStatus: the disappearing input bar is its own feedback.
|
||
}
|
||
|
||
/// Commit the in-progress ack note: dupe any final unflushed
|
||
/// fragment, write the journal entry, rebuild the findings view,
|
||
/// and reset input state.
|
||
fn commitAckNote(state: *State, app: *App) void {
|
||
defer {
|
||
state.input_mode = .normal;
|
||
state.note_len = 0;
|
||
clearNoteFragments(state, app.allocator);
|
||
}
|
||
|
||
const fv = state.findings_view orelse return;
|
||
if (fv.rows.len == 0 or state.findings_cursor >= fv.rows.len) return;
|
||
const row = fv.rows[state.findings_cursor];
|
||
|
||
const journal = if (state.journal) |*j| j else {
|
||
app.setStatus("Journal not loaded");
|
||
return;
|
||
};
|
||
|
||
const path = journalPath(app) orelse {
|
||
app.setStatus("No portfolio anchor");
|
||
return;
|
||
};
|
||
defer app.allocator.free(path);
|
||
|
||
// Flush trailing unfinished fragment, if any.
|
||
if (state.note_len > 0) {
|
||
const dup = app.allocator.dupe(u8, state.note_buf[0..state.note_len]) catch |err| {
|
||
app.setStatus("Note buffer alloc failed");
|
||
std.log.scoped(.review_tab).warn("note dup failed: {s}", .{@errorName(err)});
|
||
return;
|
||
};
|
||
state.note_fragments.append(app.allocator, dup) catch |err| {
|
||
app.allocator.free(dup);
|
||
app.setStatus("Note buffer alloc failed");
|
||
std.log.scoped(.review_tab).warn("note append failed: {s}", .{@errorName(err)});
|
||
return;
|
||
};
|
||
}
|
||
|
||
// Build a `[]const []const u8` view over the fragments for
|
||
// `journal.append`. The journal dupes them again internally,
|
||
// so our local strings are freed normally by `clearNoteFragments`.
|
||
const fragments_view = state.note_fragments.items;
|
||
|
||
journal.append(
|
||
app.io,
|
||
path,
|
||
.{
|
||
.observation = row.kind,
|
||
.target = row.target,
|
||
.acknowledged_at = app.today,
|
||
.state = .acknowledged,
|
||
},
|
||
fragments_view,
|
||
) catch |err| {
|
||
app.setStatus("Acknowledgment failed");
|
||
std.log.scoped(.review_tab).warn("journal.append failed: {s}", .{@errorName(err)});
|
||
return;
|
||
};
|
||
|
||
rebuildFindingsView(state, app);
|
||
// No setStatus on success: the visible removal of the row
|
||
// from the findings list (or the "[acked]" prefix when
|
||
// show_acked is on) is feedback enough. Leaving the prior
|
||
// help/hint visible is the user's preference.
|
||
}
|
||
|
||
// ── Data loading ──────────────────────────────────────────────
|
||
|
||
fn loadData(state: *State, app: *App) void {
|
||
state.loaded = true;
|
||
// Sync data is populated by pd.load at App init.
|
||
const pf = app.portfolio.file orelse return;
|
||
const summary_ptr = if (app.portfolio.summary) |*s| s else return;
|
||
|
||
// Lazy-load classifications (per-tab; analysis_tab loads its own copy).
|
||
if (state.classification_map == null) {
|
||
if (app.anchorPath()) |ppath| {
|
||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||
defer app.allocator.free(meta_path);
|
||
|
||
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch {
|
||
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||
return;
|
||
};
|
||
defer app.allocator.free(file_data);
|
||
|
||
state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
|
||
app.setStatus("Error parsing metadata.srf");
|
||
return;
|
||
};
|
||
}
|
||
}
|
||
|
||
// Tier 1 accessors block on their respective worker futures.
|
||
// Review needs candles, dividends, and the account map; this
|
||
// is the first tab where all three workers' costs may show
|
||
// up as visible wait. Most users will hit portfolio first
|
||
// (where snapshots blocks if needed) before navigating to
|
||
// review, so the candles + dividends workers are usually
|
||
// done by the time we get here.
|
||
const candle_map = app.portfolio.candles() orelse {
|
||
app.setStatus("Portfolio data not loaded");
|
||
return;
|
||
};
|
||
const dividend_map = app.portfolio.dividends();
|
||
const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null;
|
||
|
||
if (state.view) |*v| v.deinit(app.allocator);
|
||
state.view = review_view.buildReview(
|
||
app.allocator,
|
||
app.io,
|
||
summary_ptr.*,
|
||
candle_map,
|
||
dividend_map,
|
||
pf,
|
||
state.classification_map orelse return,
|
||
acct_map_opt,
|
||
app.today,
|
||
app.anchorPath() orelse "",
|
||
) catch {
|
||
app.setStatus("Error computing review");
|
||
return;
|
||
};
|
||
|
||
applySort(state);
|
||
rebuildFindingsView(state, app);
|
||
}
|
||
|
||
/// Resolve the absolute path to `acknowledgments.srf` next to the
|
||
/// portfolio file. Returns null when no portfolio is loaded.
|
||
/// Caller owns the returned slice.
|
||
fn journalPath(app: *App) ?[]const u8 {
|
||
const ppath = app.anchorPath() orelse return null;
|
||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||
return std.fmt.allocPrint(app.allocator, "{s}acknowledgments.srf", .{ppath[0..dir_end]}) catch null;
|
||
}
|
||
|
||
/// Load the journal from disk. Missing file ⇒ empty journal (first
|
||
/// run case). Parse error ⇒ empty journal + error status; the user
|
||
/// will see the bad file but actions won't crash. Existing
|
||
/// `state.journal` is freed first.
|
||
fn loadJournal(state: *State, app: *App) void {
|
||
if (state.journal) |*j| j.deinit();
|
||
state.journal = null;
|
||
|
||
const path = journalPath(app) orelse {
|
||
// No portfolio anchor — synthesize an empty journal so
|
||
// ack-flow code can rely on `state.journal` being non-null
|
||
// once `loadData` runs.
|
||
const empty_entries = app.allocator.alloc(Journal.Entry, 0) catch return;
|
||
state.journal = .{ .allocator = app.allocator, .entries = empty_entries };
|
||
return;
|
||
};
|
||
defer app.allocator.free(path);
|
||
|
||
state.journal = Journal.load(app.allocator, app.io, path) catch |err| {
|
||
// Surface the specific error so the user can fix the file.
|
||
// Using a stack buffer because setStatus copies into App's
|
||
// own fixed buffer; we don't need a heap allocation.
|
||
var msg_buf: [128]u8 = undefined;
|
||
const msg = std.fmt.bufPrint(
|
||
&msg_buf,
|
||
"acknowledgments.srf: {s}",
|
||
.{@errorName(err)},
|
||
) catch "acknowledgments.srf: parse error";
|
||
app.setStatus(msg);
|
||
// Synthesize empty so ack flow remains usable.
|
||
const empty_entries = app.allocator.alloc(Journal.Entry, 0) catch return;
|
||
state.journal = .{ .allocator = app.allocator, .entries = empty_entries };
|
||
return;
|
||
};
|
||
}
|
||
|
||
/// Rebuild `findings_view` from `state.view.observations` and
|
||
/// `state.journal`. Frees any prior findings_view first. Both inputs
|
||
/// must be present; if either is missing, leaves `findings_view = null`.
|
||
///
|
||
/// Always clears `expanded_finding`: any expansion held across a
|
||
/// rebuild is unsafe because row indices may have shifted (acking
|
||
/// removes a row; `show_acked` toggle adds/removes rows). The user
|
||
/// can re-expand with Enter.
|
||
fn rebuildFindingsView(state: *State, app: *App) void {
|
||
if (state.findings_view) |*fv| fv.deinit(app.allocator);
|
||
state.findings_view = null;
|
||
state.expanded_finding = null;
|
||
|
||
const view = state.view orelse return;
|
||
const panel = if (view.observations) |*p| p else return;
|
||
const journal = if (state.journal) |*j| j else return;
|
||
|
||
state.findings_view = observations_view.build(app.allocator, panel, journal, state.show_acked) catch |err| {
|
||
std.log.scoped(.review_tab).warn("findings build failed: {s}", .{@errorName(err)});
|
||
return;
|
||
};
|
||
|
||
// Clamp the cursor in case `show_acked` toggling or an ack shrunk
|
||
// the row count beneath the previous cursor position.
|
||
if (state.findings_view) |fv| {
|
||
if (fv.rows.len == 0) {
|
||
state.findings_cursor = 0;
|
||
} else if (state.findings_cursor >= fv.rows.len) {
|
||
state.findings_cursor = fv.rows.len - 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// State teardown that doesn't require an App. Lets tests exercise
|
||
/// the cleanup path directly under `testing.allocator`.
|
||
pub fn deinitState(state: *State, allocator: std.mem.Allocator) void {
|
||
if (state.view) |*v| v.deinit(allocator);
|
||
if (state.findings_view) |*fv| fv.deinit(allocator);
|
||
if (state.journal) |*j| j.deinit();
|
||
if (state.classification_map) |*cm| cm.deinit();
|
||
for (state.note_fragments.items) |frag| allocator.free(frag);
|
||
state.note_fragments.deinit(allocator);
|
||
state.* = .{};
|
||
}
|
||
|
||
// ── Rendering ─────────────────────────────────────────────────
|
||
|
||
/// Column widths in display-column order. Tax% is LAST: it's a
|
||
/// contextual hint, not a primary metric, and shouldn't anchor the
|
||
/// eye. The first numeric column the reader sees is Wt% (how big is
|
||
/// this position?), which is the right anchor for "what am I
|
||
/// looking at."
|
||
const col_symbol: usize = 8;
|
||
const col_sector: usize = 20;
|
||
const col_weight: usize = 7;
|
||
const col_pct: usize = 8;
|
||
const col_sharpe: usize = 8;
|
||
const col_maxdd: usize = 10;
|
||
const col_tax: usize = 7;
|
||
|
||
/// Display-column header tag for each column, used by the header
|
||
/// renderer, the click hit-test, and (via `sortField`) the sort
|
||
/// dispatcher when the user clicks a column header.
|
||
const Col = enum {
|
||
symbol,
|
||
sector,
|
||
weight,
|
||
return_1y,
|
||
return_3y,
|
||
return_5y,
|
||
return_10y,
|
||
vol_3y,
|
||
vol_10y,
|
||
sharpe_3y,
|
||
sharpe_10y,
|
||
maxdd_5y,
|
||
tax,
|
||
|
||
fn width(self: Col) usize {
|
||
return switch (self) {
|
||
.symbol => col_symbol,
|
||
.sector => col_sector,
|
||
.weight => col_weight,
|
||
.return_1y, .return_3y, .return_5y, .return_10y => col_pct,
|
||
.vol_3y, .vol_10y => col_pct,
|
||
.sharpe_3y, .sharpe_10y => col_sharpe,
|
||
.maxdd_5y => col_maxdd,
|
||
.tax => col_tax,
|
||
};
|
||
}
|
||
|
||
fn header(self: Col) []const u8 {
|
||
return switch (self) {
|
||
.symbol => "Symbol",
|
||
.sector => "Sector",
|
||
.weight => "Wt%",
|
||
.return_1y => "1Y",
|
||
.return_3y => "3Y",
|
||
.return_5y => "5Y",
|
||
.return_10y => "10Y",
|
||
.vol_3y => "3Y-Vol",
|
||
.vol_10y => "10Y-Vol",
|
||
.sharpe_3y => "3Y-SR",
|
||
.sharpe_10y => "10Y-SR",
|
||
.maxdd_5y => "5Y-MaxDD",
|
||
.tax => "Tax%",
|
||
};
|
||
}
|
||
|
||
/// Map this column to the corresponding view-level SortField.
|
||
/// All review columns are sortable today, but the function
|
||
/// returns an optional so future "non-sortable" columns
|
||
/// (e.g. an action button) can decline.
|
||
fn sortField(self: Col) ?review_view.SortField {
|
||
return switch (self) {
|
||
.symbol => .symbol,
|
||
.sector => .sector,
|
||
.weight => .weight,
|
||
.return_1y => .return_1y,
|
||
.return_3y => .return_3y,
|
||
.return_5y => .return_5y,
|
||
.return_10y => .return_10y,
|
||
.vol_3y => .vol_3y,
|
||
.vol_10y => .vol_10y,
|
||
.sharpe_3y => .sharpe_3y,
|
||
.sharpe_10y => .sharpe_10y,
|
||
.maxdd_5y => .maxdd_5y,
|
||
.tax => .tax_pct,
|
||
};
|
||
}
|
||
|
||
/// Default sort direction for this column when freshly
|
||
/// selected. String columns sort ascending (alphabetical);
|
||
/// numeric columns sort descending (best first). MaxDD is the
|
||
/// odd one — descending puts the WORST drawdowns first, which
|
||
/// is what the user actually wants ("show me the most-bruised
|
||
/// holdings"). Vol same logic.
|
||
fn defaultDir(self: Col) review_view.SortDirection {
|
||
return switch (self) {
|
||
.symbol, .sector => .asc,
|
||
else => .desc,
|
||
};
|
||
}
|
||
};
|
||
|
||
/// Order in which columns appear in the rendered row.
|
||
const col_order = [_]Col{
|
||
.symbol, .sector,
|
||
.weight, .return_1y,
|
||
.return_3y, .return_5y,
|
||
.return_10y, .vol_3y,
|
||
.vol_10y, .sharpe_3y,
|
||
.sharpe_10y, .maxdd_5y,
|
||
.tax,
|
||
};
|
||
|
||
/// Bytes consumed by `RowBuilder.prefix` at the start of every
|
||
/// row. Click hit-testing offsets by this amount.
|
||
const row_prefix_cols: usize = 2;
|
||
|
||
/// Map a click column (`mouse.col`, screen columns from the left)
|
||
/// onto a `Col` value, accounting for the row prefix and the
|
||
/// single-space gap between columns. Returns null when the click
|
||
/// lands in the prefix, on a gap, or past the right edge of the
|
||
/// last column.
|
||
fn hitTestHeader(click_col: usize) ?Col {
|
||
if (click_col < row_prefix_cols) return null;
|
||
var pos = row_prefix_cols;
|
||
inline for (col_order, 0..) |col, idx| {
|
||
if (idx > 0) {
|
||
// Gap column: clicks here are unambiguously between
|
||
// two columns. Treat them as "no hit" rather than
|
||
// surprising the user by attributing them to either
|
||
// neighbor.
|
||
if (click_col == pos) return null;
|
||
pos += 1;
|
||
}
|
||
const col_end = pos + col.width();
|
||
if (click_col >= pos and click_col < col_end) return col;
|
||
pos = col_end;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Builds a row's text + per-cell style spans simultaneously. Each
|
||
/// `cell()` call appends one cell (padded to its target display width)
|
||
/// and records a span for any non-default intent.
|
||
const RowBuilder = struct {
|
||
arena: std.mem.Allocator,
|
||
th: theme.Theme,
|
||
text: std.ArrayList(u8) = .empty,
|
||
spans: std.ArrayList(StyleSpan) = .empty,
|
||
/// Display column the next cell will start at. Updated by
|
||
/// `cell()` after each append.
|
||
col: usize = 0,
|
||
|
||
/// Append the leading row-prefix indent (matches " " at the
|
||
/// start of every line in the file).
|
||
fn prefix(self: *RowBuilder) !void {
|
||
try self.text.appendSlice(self.arena, " ");
|
||
self.col += 2;
|
||
}
|
||
|
||
/// Append a padded cell with content `s` (already-formatted text)
|
||
/// at display width `w`. Alignment: right (numeric / dash cells).
|
||
/// Records a style span if `intent` resolves to a non-default
|
||
/// style (positive/negative/warning/muted).
|
||
fn cellRight(self: *RowBuilder, s: []const u8, w: usize, intent: fmt.StyleIntent) !void {
|
||
var pad_buf: [64]u8 = undefined;
|
||
const padded = fmt.padLeftToCols(&pad_buf, s, w);
|
||
try self.appendCell(padded, w, intent);
|
||
}
|
||
|
||
/// Append a left-aligned padded cell (used for symbol, sector
|
||
/// labels). Pads with trailing spaces. Truncates input to width
|
||
/// when oversize. Display-column aware on the input length so
|
||
/// abbreviated multibyte sectors align correctly.
|
||
fn cellLeft(self: *RowBuilder, s: []const u8, w: usize, intent: fmt.StyleIntent) !void {
|
||
const trimmed = fmt.truncateToCols(s, w);
|
||
var pad_buf: [128]u8 = undefined;
|
||
const have_cols = fmt.displayCols(trimmed);
|
||
const pad_cols = if (have_cols < w) w - have_cols else 0;
|
||
// Layout: [content bytes][pad_cols spaces].
|
||
if (trimmed.len + pad_cols > pad_buf.len) {
|
||
try self.appendCell(trimmed, w, intent);
|
||
return;
|
||
}
|
||
@memcpy(pad_buf[0..trimmed.len], trimmed);
|
||
@memset(pad_buf[trimmed.len .. trimmed.len + pad_cols], ' ');
|
||
try self.appendCell(pad_buf[0 .. trimmed.len + pad_cols], w, intent);
|
||
}
|
||
|
||
/// Append a single-space gap between cells.
|
||
fn gap(self: *RowBuilder) !void {
|
||
try self.text.append(self.arena, ' ');
|
||
self.col += 1;
|
||
}
|
||
|
||
fn appendCell(
|
||
self: *RowBuilder,
|
||
padded: []const u8,
|
||
w: usize,
|
||
intent: fmt.StyleIntent,
|
||
) !void {
|
||
const start = self.col;
|
||
try self.text.appendSlice(self.arena, padded);
|
||
// Trust that `padded` is exactly `w` display columns wide
|
||
// (every caller guarantees that via padLeft/padRight).
|
||
const end = start + w;
|
||
self.col = end;
|
||
if (intent != .normal) {
|
||
try self.spans.append(self.arena, .{
|
||
.start = start,
|
||
.end = end,
|
||
.style = self.th.styleFor(intent),
|
||
});
|
||
}
|
||
}
|
||
|
||
fn build(self: *RowBuilder, base_style: vaxis.Style) !StyledLine {
|
||
const text = try self.text.toOwnedSlice(self.arena);
|
||
const spans = try self.spans.toOwnedSlice(self.arena);
|
||
return .{
|
||
.text = text,
|
||
.style = base_style,
|
||
.spans = if (spans.len > 0) spans else null,
|
||
};
|
||
}
|
||
};
|
||
|
||
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
const th = app.theme;
|
||
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
try lines.append(arena, .{ .text = " Portfolio Review", .style = th.headerStyle() });
|
||
|
||
const view = state.view orelse {
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
try lines.append(arena, .{ .text = " No data. Load a portfolio with -p <path>.", .style = th.mutedStyle() });
|
||
return lines.toOwnedSlice(arena);
|
||
};
|
||
|
||
try lines.append(arena, .{
|
||
.text = try std.fmt.allocPrint(arena, " As of {f} Liquid: {f} Holdings: {d}", .{
|
||
view.as_of, Money.from(view.total_liquid), view.rows.len,
|
||
}),
|
||
.style = th.mutedStyle(),
|
||
});
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
|
||
// Status grid: per-check pass/warn/flag glyphs at the top of
|
||
// the tab, before the holdings table. Gives the user an
|
||
// at-a-glance "what's wrong" before they scan rows. Rendered
|
||
// even when zero findings (the all-✅ row IS the signal).
|
||
if (view.observations) |panel| {
|
||
try appendStatusGrid(arena, &lines, panel, th);
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
}
|
||
|
||
// Header row — purple+bold (`headerStyle`) with sort indicators
|
||
// on the active column. Record the row index so `handleMouse`
|
||
// can detect column-header clicks for click-to-sort.
|
||
state.header_row = lines.items.len;
|
||
try lines.append(arena, try buildHeaderLine(arena, th, state.sort_field, state.sort_dir));
|
||
|
||
// Sort-status indicator (so user sees what they're sorted by).
|
||
const sort_label = sortFieldLabel(state.sort_field);
|
||
const dir_arrow: []const u8 = if (state.sort_dir == .asc) "↑" else "↓";
|
||
try lines.append(arena, .{
|
||
.text = try std.fmt.allocPrint(arena, " Sort: {s} {s} [<] prev [>] next [o] reverse", .{
|
||
sort_label, dir_arrow,
|
||
}),
|
||
.style = th.dimStyle(),
|
||
});
|
||
|
||
// Rows.
|
||
state.holdings_first_row = lines.items.len;
|
||
for (view.rows) |r| {
|
||
try lines.append(arena, try formatRow(arena, th, r));
|
||
}
|
||
|
||
// Totals separator.
|
||
try lines.append(arena, try buildSeparatorLine(arena, th));
|
||
|
||
// Totals row.
|
||
try lines.append(arena, try formatTotalsRow(arena, th, view.totals));
|
||
|
||
if (anyReweightFlag(view.totals.reweight_flags)) {
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
try lines.append(arena, .{
|
||
.text = " * Reweighted: at least one holding lacked full-window candle coverage.",
|
||
.style = th.mutedStyle(),
|
||
});
|
||
try lines.append(arena, .{
|
||
.text = " Affected metrics renormalized weights across participating holdings.",
|
||
.style = th.mutedStyle(),
|
||
});
|
||
}
|
||
|
||
// Findings section. Rendered below the totals row + reweight
|
||
// footer. M2 step 8a: simple severity-glyph + text rows with
|
||
// cursor highlight; expansion + inline note input land in 8b.
|
||
if (state.findings_view) |fv| {
|
||
try appendFindingsSection(arena, &lines, state, fv, th);
|
||
}
|
||
|
||
return lines.toOwnedSlice(arena);
|
||
}
|
||
|
||
/// Glyph used in the findings table's severity column. Each is two
|
||
/// display columns wide (emoji-presentation) so renderers don't have
|
||
/// to width-correct.
|
||
/// Per-finding-row glyph indicating severity. Each glyph string
|
||
/// includes a trailing U+FE0F variation selector to force emoji
|
||
/// presentation. The variation selector also serves a critical
|
||
/// rendering role here: `drawStyledContent` allocates one buffer
|
||
/// cell per UTF-8 sequence (advancing col by 1), so a single-
|
||
/// codepoint emoji like ❌ takes 1 buffer cell while terminals
|
||
/// render it as 2 visual cols, desyncing the col counter from
|
||
/// terminal display. Appending FE0F gives it a second codepoint
|
||
/// (which gets its own cell) so buffer-col advancement matches
|
||
/// terminal-col advancement at exactly 2 per emoji.
|
||
fn severityGlyph(sev: observations.Severity) []const u8 {
|
||
return switch (sev) {
|
||
.warn => "⚠️", // U+26A0 + FE0F
|
||
.flag => "❌\u{FE0F}",
|
||
.err => "🛑\u{FE0F}",
|
||
};
|
||
}
|
||
|
||
/// Glyph for an entire check's state — what shows in the status
|
||
/// grid at the top of the tab. `pass` and `skipped` get distinct
|
||
/// glyphs (not just "no glyph") because users want to see "yes I
|
||
/// ran every check, here's why each one is OK".
|
||
///
|
||
/// Pending is reserved for the future async dispatch path; today
|
||
/// every check is sync so we never produce it. Keeping the case
|
||
/// in the renderer means the renderer is ready when the async
|
||
/// path lands.
|
||
/// Per-check status-grid glyph. See `severityGlyph` for the
|
||
/// FE0F-trailing convention — it forces emoji presentation and
|
||
/// gives the renderer a second cell to track so buffer-col
|
||
/// advancement matches terminal-col advancement.
|
||
fn checkStatusGlyph(result: observations.CheckResult) []const u8 {
|
||
return switch (result) {
|
||
.pass => "✅\u{FE0F}",
|
||
.warn => "⚠️", // U+26A0 + FE0F (already 2 codepoints)
|
||
.flag => "❌\u{FE0F}",
|
||
.skipped => "➖\u{FE0F}",
|
||
.err => "🛑\u{FE0F}",
|
||
};
|
||
}
|
||
|
||
/// Theme style for a status-grid cell. Mirrors finding-row styling
|
||
/// so the status grid and the findings table feel cohesive.
|
||
fn checkStatusStyle(th: theme.Theme, result: observations.CheckResult) vaxis.Style {
|
||
return switch (result) {
|
||
.pass => th.mutedStyle(),
|
||
.warn => th.warningStyle(),
|
||
.flag, .err => th.negativeStyle(),
|
||
.skipped => th.mutedStyle(),
|
||
};
|
||
}
|
||
|
||
/// Width of one status-grid cell's label component, in display
|
||
/// columns. Sized to fit the longest registered check label.
|
||
/// Currently "Position concentration" at 22 chars is the longest;
|
||
/// "Sector concentration" (20), "Drift since last view" (21), and
|
||
/// "Sector dominance" (16) are all shorter.
|
||
const status_label_cols: usize = 22;
|
||
|
||
/// Number of cells per row in the status grid. 3 is the safe
|
||
/// default for ~110-column terminals; 4 fits if the terminal is
|
||
/// wider but we'd need to plumb terminal width through, which
|
||
/// hasn't been worth the layering yet. 3-up is what the design
|
||
/// committed to as the v1 baseline.
|
||
const status_cells_per_row: usize = 3;
|
||
|
||
/// Append the per-check status grid to `lines`. One row per
|
||
/// `status_cells_per_row` checks, each cell laid out as
|
||
/// "<right-padded label> <glyph>". Cells separated by 2 spaces.
|
||
///
|
||
/// Style: each cell takes its severity's color (warn = yellow,
|
||
/// flag/err = red, pass/skipped = muted). Because StyledLine is
|
||
/// one-style-per-line, every cell on the line gets the same
|
||
/// style. To preserve per-cell color, we emit one StyledLine
|
||
/// PER CELL — visually still on the same row because vaxis
|
||
/// concatenates lines that don't span the full width...
|
||
///
|
||
/// Actually no, each StyledLine takes its own row. So we need to
|
||
/// pre-pick one style per row by promoting the worst severity in
|
||
/// that row's cells. That keeps the line count bounded and each
|
||
/// row's color signals "this row contains at least one warn /
|
||
/// flag" without per-cell colors.
|
||
fn appendStatusGrid(
|
||
arena: std.mem.Allocator,
|
||
lines: *std.ArrayList(StyledLine),
|
||
panel: observations.CheckPanel,
|
||
th: theme.Theme,
|
||
) !void {
|
||
if (panel.pending.len == 0) return;
|
||
|
||
var i: usize = 0;
|
||
while (i < panel.pending.len) {
|
||
const end = @min(i + status_cells_per_row, panel.pending.len);
|
||
|
||
// Promote the row's worst-severity color so multi-cell rows
|
||
// with mixed states still draw the user's eye to the bad
|
||
// ones. Order from best to worst: pass < skipped < warn <
|
||
// flag/err.
|
||
var row_style = th.mutedStyle();
|
||
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
||
for (panel.pending[i..end]) |pc| {
|
||
const result = pc.state.complete;
|
||
const rank: u8 = switch (result) {
|
||
.pass, .skipped => 0,
|
||
.warn => 1,
|
||
.flag, .err => 2,
|
||
};
|
||
if (rank > worst) {
|
||
worst = rank;
|
||
row_style = checkStatusStyle(th, result);
|
||
}
|
||
}
|
||
|
||
var text: std.ArrayList(u8) = .empty;
|
||
try text.appendSlice(arena, " ");
|
||
for (panel.pending[i..end], 0..) |pc, col| {
|
||
if (col > 0) try text.appendSlice(arena, " ");
|
||
try appendStatusCell(arena, &text, pc.check.label, pc.state.complete);
|
||
}
|
||
try lines.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = row_style });
|
||
|
||
i = end;
|
||
}
|
||
}
|
||
|
||
/// Append one cell's bytes to `text`: right-padded label + space
|
||
/// + glyph. With ASCII glyphs (always 1 col), the cell ends at
|
||
/// exactly `status_cell_cols` display columns.
|
||
fn appendStatusCell(
|
||
arena: std.mem.Allocator,
|
||
text: *std.ArrayList(u8),
|
||
label: []const u8,
|
||
result: observations.CheckResult,
|
||
) !void {
|
||
const lbl_cols = label.len; // ASCII labels: byte count == display cols
|
||
if (lbl_cols < status_label_cols) {
|
||
try text.appendNTimes(arena, ' ', status_label_cols - lbl_cols);
|
||
}
|
||
try text.appendSlice(arena, label);
|
||
try text.append(arena, ' ');
|
||
try text.appendSlice(arena, checkStatusGlyph(result));
|
||
}
|
||
|
||
/// Append the findings section to `lines`. Layout:
|
||
///
|
||
/// <blank line>
|
||
/// Findings (N active, M acked)
|
||
/// <separator>
|
||
/// <glyph> <text> ← per row
|
||
/// <glyph> <text> ← acked rows shown only when state.show_acked
|
||
///
|
||
/// Cursor highlight: when `state.focus == .findings` and the row
|
||
/// index matches `state.findings_cursor`, the row gets the theme's
|
||
/// selected-style applied.
|
||
fn appendFindingsSection(
|
||
arena: std.mem.Allocator,
|
||
lines: *std.ArrayList(StyledLine),
|
||
state: *State,
|
||
fv: observations_view.FindingsView,
|
||
th: theme.Theme,
|
||
) !void {
|
||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||
|
||
const heading = try std.fmt.allocPrint(
|
||
arena,
|
||
" Findings ({d} active, {d} acked, {d} resolved){s}",
|
||
.{
|
||
fv.total_active,
|
||
fv.total_acked,
|
||
fv.total_resolved,
|
||
if (state.show_acked) " [showing acked]" else "",
|
||
},
|
||
);
|
||
try lines.append(arena, .{ .text = heading, .style = th.headerStyle() });
|
||
try lines.append(arena, try buildSeparatorLine(arena, th));
|
||
|
||
if (fv.rows.len == 0) {
|
||
const msg = if (fv.total_acked > 0 and !state.show_acked)
|
||
" No active findings. Press 'v' to show acknowledged."
|
||
else
|
||
" No findings.";
|
||
try lines.append(arena, .{ .text = msg, .style = th.mutedStyle() });
|
||
state.findings_first_row = 0;
|
||
state.findings_row_count = 0;
|
||
return;
|
||
}
|
||
|
||
state.findings_first_row = lines.items.len;
|
||
const start_count = lines.items.len;
|
||
|
||
for (fv.rows, 0..) |row, i| {
|
||
const is_cursor = state.focus == .findings and i == state.findings_cursor;
|
||
const text = try std.fmt.allocPrint(arena, " {s} {s}{s}", .{
|
||
severityGlyph(row.severity),
|
||
if (row.is_acked) "[acked] " else "",
|
||
row.text,
|
||
});
|
||
const style = if (is_cursor)
|
||
th.selectStyle()
|
||
else if (row.is_acked)
|
||
th.mutedStyle()
|
||
else switch (row.severity) {
|
||
.warn => th.warningStyle(),
|
||
.flag, .err => th.negativeStyle(),
|
||
};
|
||
try lines.append(arena, .{ .text = text, .style = style });
|
||
|
||
// Inline expansion: when this is the expanded finding,
|
||
// render the details + ack notes (if any) immediately
|
||
// below. Plus the inline input bar when we're collecting
|
||
// an ack note for this finding.
|
||
if (state.expanded_finding == i) {
|
||
try appendExpansionLines(arena, lines, row, th);
|
||
if (state.input_mode == .ack_note) {
|
||
try appendInputBarLines(arena, lines, state, th);
|
||
}
|
||
}
|
||
}
|
||
|
||
state.findings_row_count = lines.items.len - start_count;
|
||
}
|
||
|
||
/// Append the expanded-row detail lines for a finding. For now:
|
||
/// `kind/target` line, separator-style note lines if the row is
|
||
/// acked. Future: ack date, multi-line note rendering, the
|
||
/// inline note-input bar (step 8b).
|
||
fn appendExpansionLines(
|
||
arena: std.mem.Allocator,
|
||
lines: *std.ArrayList(StyledLine),
|
||
row: observations_view.FindingRow,
|
||
th: theme.Theme,
|
||
) !void {
|
||
const detail = try std.fmt.allocPrint(arena, " kind: {s} target: {s}", .{
|
||
row.kind,
|
||
row.target,
|
||
});
|
||
try lines.append(arena, .{ .text = detail, .style = th.mutedStyle() });
|
||
|
||
if (row.ack_entry) |entry| {
|
||
const ack_line = try std.fmt.allocPrint(arena, " acknowledged {f}", .{
|
||
entry.ack.acknowledged_at,
|
||
});
|
||
try lines.append(arena, .{ .text = ack_line, .style = th.mutedStyle() });
|
||
for (entry.notes) |note| {
|
||
const note_line = try std.fmt.allocPrint(arena, " | {s}", .{note});
|
||
try lines.append(arena, .{ .text = note_line, .style = th.mutedStyle() });
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Append the inline note-input bar lines for the active ack flow.
|
||
/// Layout: one line per already-completed fragment (style: input),
|
||
/// then one line for the in-progress fragment with a trailing `_`
|
||
/// to indicate the cursor. Final line: hint about Enter / Ctrl+Enter /
|
||
/// Esc bindings.
|
||
fn appendInputBarLines(
|
||
arena: std.mem.Allocator,
|
||
lines: *std.ArrayList(StyledLine),
|
||
state: *const State,
|
||
th: theme.Theme,
|
||
) !void {
|
||
for (state.note_fragments.items) |frag| {
|
||
const line = try std.fmt.allocPrint(arena, " > {s}", .{frag});
|
||
try lines.append(arena, .{ .text = line, .style = th.inputStyle() });
|
||
}
|
||
const in_progress = try std.fmt.allocPrint(arena, " > {s}_", .{state.note_buf[0..state.note_len]});
|
||
try lines.append(arena, .{ .text = in_progress, .style = th.inputStyle() });
|
||
try lines.append(arena, .{
|
||
.text = " [Enter] next line [Ctrl+Enter] save [Esc] cancel",
|
||
.style = th.inputHintStyle(),
|
||
});
|
||
}
|
||
|
||
/// Build the column-header row, with sort indicators on the
|
||
/// active column and the same purple/bold header style the
|
||
/// portfolio tab uses. The active sort column embeds a `▲`/`▼`
|
||
/// glyph (one display column, replacing one space of padding) so
|
||
/// the user can see at a glance which column they're sorted by
|
||
/// without reading the "Sort:" line below.
|
||
fn buildHeaderLine(
|
||
arena: std.mem.Allocator,
|
||
th: theme.Theme,
|
||
sort_field: review_view.SortField,
|
||
sort_dir: review_view.SortDirection,
|
||
) !StyledLine {
|
||
var rb: RowBuilder = .{ .arena = arena, .th = th };
|
||
try rb.prefix();
|
||
// Per-column header, with `colLabel` baking in a sort indicator
|
||
// when the column matches the active sort field. `colLabel`
|
||
// returns a slice whose display width equals the column width;
|
||
// we hand it to `appendCell` directly without re-padding.
|
||
inline for (col_order, 0..) |col, idx| {
|
||
if (idx > 0) try rb.gap();
|
||
const ind: ?[]const u8 = blk: {
|
||
const col_sf = col.sortField() orelse break :blk null;
|
||
if (sort_field == col_sf) break :blk sort_dir.indicator();
|
||
break :blk null;
|
||
};
|
||
// `comptime` widths required by colLabel.
|
||
const w = comptime col.width();
|
||
var buf: [64]u8 = undefined;
|
||
const left_align = (col == .symbol or col == .sector);
|
||
const lbl = tui.colLabel(&buf, col.header(), w, left_align, ind);
|
||
try rb.appendCell(lbl, w, .normal);
|
||
}
|
||
return rb.build(th.headerStyle());
|
||
}
|
||
|
||
fn buildSeparatorLine(arena: std.mem.Allocator, th: theme.Theme) !StyledLine {
|
||
var sep: std.ArrayList(u8) = .empty;
|
||
try sep.appendSlice(arena, " ");
|
||
inline for (col_order, 0..) |col, idx| {
|
||
if (idx > 0) try sep.append(arena, ' ');
|
||
var k: usize = 0;
|
||
while (k < col.width()) : (k += 1) try sep.appendSlice(arena, "─");
|
||
}
|
||
return .{ .text = try sep.toOwnedSlice(arena), .style = th.mutedStyle() };
|
||
}
|
||
|
||
/// Solid horizontal rule the same display width as the holdings
|
||
/// table separator, used under the Findings heading. The findings
|
||
/// rows aren't column-aligned (they're free-text wrap) so a
|
||
/// column-gapped rule looks broken there. This one's just a
|
||
/// continuous run of `─`.
|
||
fn buildSolidSeparatorLine(arena: std.mem.Allocator, th: theme.Theme) !StyledLine {
|
||
// Total display width matches `buildSeparatorLine`:
|
||
// 2 (prefix) + sum(col widths) + (n_cols - 1) (single-space gaps)
|
||
const total_width: usize = comptime blk: {
|
||
var sum: usize = 2;
|
||
for (col_order, 0..) |col, idx| {
|
||
if (idx > 0) sum += 1;
|
||
sum += col.width();
|
||
}
|
||
break :blk sum;
|
||
};
|
||
var sep: std.ArrayList(u8) = .empty;
|
||
try sep.appendSlice(arena, " ");
|
||
var k: usize = 2;
|
||
while (k < total_width) : (k += 1) try sep.appendSlice(arena, "─");
|
||
return .{ .text = try sep.toOwnedSlice(arena), .style = th.mutedStyle() };
|
||
}
|
||
|
||
fn anyReweightFlag(f: portfolio_risk.ReweightFlags) bool {
|
||
return f.vol_3y or f.vol_10y or f.sharpe_3y or f.sharpe_10y or f.maxdd_5y or
|
||
f.return_1y or f.return_3y or f.return_5y or f.return_10y;
|
||
}
|
||
|
||
fn sortFieldLabel(f: review_view.SortField) []const u8 {
|
||
return switch (f) {
|
||
.symbol => "Symbol",
|
||
// Sector sort bakes in a symbol-asc tiebreaker (see
|
||
// `views/review.sortRows`); the suffix tells the user
|
||
// why VTI ends up before AAPL inside the same sector.
|
||
.sector => "Sector (Symbol tiebreaker)",
|
||
.weight => "Weight",
|
||
.tax_pct => "Tax%",
|
||
.return_1y => "1Y Return",
|
||
.return_3y => "3Y Return",
|
||
.return_5y => "5Y Return",
|
||
.return_10y => "10Y Return",
|
||
.vol_3y => "3Y Vol",
|
||
.vol_10y => "10Y Vol",
|
||
.sharpe_3y => "3Y Sharpe",
|
||
.sharpe_10y => "10Y Sharpe",
|
||
.maxdd_5y => "5Y MaxDD",
|
||
};
|
||
}
|
||
|
||
/// Format a holding row with per-cell intent-driven coloring.
|
||
fn formatRow(
|
||
arena: std.mem.Allocator,
|
||
th: theme.Theme,
|
||
r: review_view.ReviewRow,
|
||
) !StyledLine {
|
||
var rb: RowBuilder = .{ .arena = arena, .th = th };
|
||
try rb.prefix();
|
||
|
||
var pct_buf: [16]u8 = undefined;
|
||
|
||
try rb.cellLeft(r.symbol, col_symbol, .normal);
|
||
try rb.gap();
|
||
|
||
try rb.cellLeft(zfin.analysis.abbreviateSector(r.sector_mid), col_sector, .normal);
|
||
try rb.gap();
|
||
|
||
try rb.cellRight(fmt.fmtPct(&pct_buf, r.weight, .{}), col_weight, .normal);
|
||
try rb.gap();
|
||
|
||
var b1: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b1, r.return_1y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_1y));
|
||
try rb.gap();
|
||
var b2: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b2, r.return_3y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_3y));
|
||
try rb.gap();
|
||
var b3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b3, r.return_5y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_5y));
|
||
try rb.gap();
|
||
var b4: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b4, r.return_10y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_10y));
|
||
try rb.gap();
|
||
|
||
var v3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&v3, r.vol_3y, .{}), col_pct, review_view.volIntent(r.vol_3y));
|
||
try rb.gap();
|
||
var v10: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&v10, r.vol_10y, .{}), col_pct, review_view.volIntent(r.vol_10y));
|
||
try rb.gap();
|
||
|
||
var s3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtSharpeOpt(&s3, r.sharpe_3y, .{}), col_sharpe, review_view.sharpeIntent(r.sharpe_3y));
|
||
try rb.gap();
|
||
var s10: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtSharpeOpt(&s10, r.sharpe_10y, .{}), col_sharpe, review_view.sharpeIntent(r.sharpe_10y));
|
||
try rb.gap();
|
||
|
||
var dd: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&dd, r.maxdd_5y, .{}), col_maxdd, review_view.maxddIntent(r.maxdd_5y));
|
||
try rb.gap();
|
||
|
||
var tax: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&tax, r.tax_pct, .{}), col_tax, .muted);
|
||
|
||
return rb.build(th.contentStyle());
|
||
}
|
||
|
||
fn formatTotalsRow(
|
||
arena: std.mem.Allocator,
|
||
th: theme.Theme,
|
||
t: review_view.ReviewTotals,
|
||
) !StyledLine {
|
||
var rb: RowBuilder = .{ .arena = arena, .th = th };
|
||
try rb.prefix();
|
||
|
||
try rb.cellLeft("Total", col_symbol, .normal);
|
||
try rb.gap();
|
||
try rb.cellLeft("", col_sector, .normal);
|
||
try rb.gap();
|
||
|
||
var pct_buf: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPct(&pct_buf, t.weight, .{}), col_weight, .normal);
|
||
try rb.gap();
|
||
|
||
var b1: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b1, t.return_1y, .{ .signed = true, .asterisk = t.reweight_flags.return_1y }), col_pct, review_view.returnIntent(t.return_1y));
|
||
try rb.gap();
|
||
var b2: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b2, t.return_3y, .{ .signed = true, .asterisk = t.reweight_flags.return_3y }), col_pct, review_view.returnIntent(t.return_3y));
|
||
try rb.gap();
|
||
var b3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b3, t.return_5y, .{ .signed = true, .asterisk = t.reweight_flags.return_5y }), col_pct, review_view.returnIntent(t.return_5y));
|
||
try rb.gap();
|
||
var b4: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&b4, t.return_10y, .{ .signed = true, .asterisk = t.reweight_flags.return_10y }), col_pct, review_view.returnIntent(t.return_10y));
|
||
try rb.gap();
|
||
|
||
var v3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&v3, t.vol_3y, .{ .asterisk = t.reweight_flags.vol_3y }), col_pct, review_view.volIntent(t.vol_3y));
|
||
try rb.gap();
|
||
var v10: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&v10, t.vol_10y, .{ .asterisk = t.reweight_flags.vol_10y }), col_pct, review_view.volIntent(t.vol_10y));
|
||
try rb.gap();
|
||
|
||
var s3: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtSharpeOpt(&s3, t.sharpe_3y, .{ .asterisk = t.reweight_flags.sharpe_3y }), col_sharpe, review_view.sharpeIntent(t.sharpe_3y));
|
||
try rb.gap();
|
||
var s10: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtSharpeOpt(&s10, t.sharpe_10y, .{ .asterisk = t.reweight_flags.sharpe_10y }), col_sharpe, review_view.sharpeIntent(t.sharpe_10y));
|
||
try rb.gap();
|
||
|
||
var dd: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&dd, t.maxdd_5y, .{ .asterisk = t.reweight_flags.maxdd_5y }), col_maxdd, review_view.maxddIntent(t.maxdd_5y));
|
||
try rb.gap();
|
||
|
||
var tax: [16]u8 = undefined;
|
||
try rb.cellRight(fmt.fmtPctOpt(&tax, t.tax_pct, .{}), col_tax, .muted);
|
||
|
||
return rb.build(th.headerStyle());
|
||
}
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────
|
||
|
||
const testing = std.testing;
|
||
|
||
test "nextSortField: cycles forward and wraps at end" {
|
||
// sortable_fields starts with .symbol; from .symbol → .sector.
|
||
try testing.expectEqual(review_view.SortField.sector, nextSortField(.symbol));
|
||
// From the last entry (.tax_pct), wraps to the first.
|
||
try testing.expectEqual(review_view.SortField.symbol, nextSortField(.tax_pct));
|
||
// From .maxdd_5y (second-to-last) → .tax_pct (last).
|
||
try testing.expectEqual(review_view.SortField.tax_pct, nextSortField(.maxdd_5y));
|
||
}
|
||
|
||
test "prevSortField: cycles backward and wraps at start" {
|
||
// .symbol is first; prev wraps to last (.tax_pct).
|
||
try testing.expectEqual(review_view.SortField.tax_pct, prevSortField(.symbol));
|
||
try testing.expectEqual(review_view.SortField.symbol, prevSortField(.sector));
|
||
// .tax_pct (last) ← .maxdd_5y.
|
||
try testing.expectEqual(review_view.SortField.maxdd_5y, prevSortField(.tax_pct));
|
||
}
|
||
|
||
test "Col.sortField: every column has a sort target" {
|
||
inline for (std.meta.fields(Col)) |f| {
|
||
const c: Col = @enumFromInt(f.value);
|
||
try testing.expect(c.sortField() != null);
|
||
}
|
||
}
|
||
|
||
test "Col.defaultDir: strings asc, numerics desc" {
|
||
try testing.expectEqual(review_view.SortDirection.asc, Col.symbol.defaultDir());
|
||
try testing.expectEqual(review_view.SortDirection.asc, Col.sector.defaultDir());
|
||
try testing.expectEqual(review_view.SortDirection.desc, Col.weight.defaultDir());
|
||
try testing.expectEqual(review_view.SortDirection.desc, Col.return_3y.defaultDir());
|
||
try testing.expectEqual(review_view.SortDirection.desc, Col.maxdd_5y.defaultDir());
|
||
try testing.expectEqual(review_view.SortDirection.desc, Col.tax.defaultDir());
|
||
}
|
||
|
||
test "hitTestHeader: prefix click misses" {
|
||
try testing.expect(hitTestHeader(0) == null);
|
||
try testing.expect(hitTestHeader(1) == null);
|
||
}
|
||
|
||
test "hitTestHeader: column-start hits" {
|
||
// Symbol cell starts at col 2 (after the 2-col prefix).
|
||
try testing.expectEqual(Col.symbol, hitTestHeader(2).?);
|
||
try testing.expectEqual(Col.symbol, hitTestHeader(9).?); // last col of symbol (width 8)
|
||
// Gap between symbol(end=10) and sector starts at 10 → null.
|
||
try testing.expect(hitTestHeader(10) == null);
|
||
// Sector occupies cols 11..30 (width 20).
|
||
try testing.expectEqual(Col.sector, hitTestHeader(11).?);
|
||
try testing.expectEqual(Col.sector, hitTestHeader(30).?);
|
||
// Gap at 31 → null; weight at 32..38 (width 7).
|
||
try testing.expect(hitTestHeader(31) == null);
|
||
try testing.expectEqual(Col.weight, hitTestHeader(32).?);
|
||
}
|
||
|
||
test "hitTestHeader: tax (last column) is hittable" {
|
||
// Tax is the rightmost column. Compute its expected start by
|
||
// walking col_order — easier than hardcoding column-end math
|
||
// that drifts if a future change inserts a column.
|
||
var pos: usize = row_prefix_cols;
|
||
inline for (col_order, 0..) |c, idx| {
|
||
if (idx > 0) pos += 1; // gap
|
||
if (c == .tax) break;
|
||
pos += c.width();
|
||
}
|
||
try testing.expectEqual(Col.tax, hitTestHeader(pos).?);
|
||
try testing.expectEqual(Col.tax, hitTestHeader(pos + Col.tax.width() - 1).?);
|
||
// Past the right edge → null.
|
||
try testing.expect(hitTestHeader(pos + Col.tax.width()) == null);
|
||
}
|
||
|
||
test "anyReweightFlag: detects any flag" {
|
||
try testing.expectEqual(false, anyReweightFlag(.{}));
|
||
try testing.expectEqual(true, anyReweightFlag(.{ .vol_3y = true }));
|
||
try testing.expectEqual(true, anyReweightFlag(.{ .return_10y = true }));
|
||
try testing.expectEqual(true, anyReweightFlag(.{ .maxdd_5y = true }));
|
||
}
|
||
|
||
test "sortFieldLabel: covers every field variant" {
|
||
inline for (std.meta.fields(review_view.SortField)) |f| {
|
||
const variant: review_view.SortField = @enumFromInt(f.value);
|
||
const label = sortFieldLabel(variant);
|
||
try testing.expect(label.len > 0);
|
||
}
|
||
}
|
||
|
||
test "formatRow: produces a styled line containing symbol + sector" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const r: review_view.ReviewRow = .{
|
||
.symbol = "VTI",
|
||
.sector_mid = "Diversified",
|
||
.tax_pct = 0.40,
|
||
.weight = 0.33,
|
||
.return_1y = 0.15,
|
||
.return_3y = 0.18,
|
||
.return_5y = 0.12,
|
||
.return_10y = 0.14,
|
||
.vol_3y = 0.16,
|
||
.vol_10y = 0.17,
|
||
.sharpe_3y = 1.10,
|
||
.sharpe_10y = 0.85,
|
||
.maxdd_5y = 0.25,
|
||
};
|
||
const line = try formatRow(arena, theme.default_theme, r);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "VTI") != null);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "Diversified") != null);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "+15.0%") != null);
|
||
}
|
||
|
||
test "formatRow: nulls render as em-dashes" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const r: review_view.ReviewRow = .{
|
||
.symbol = "NEW",
|
||
.sector_mid = "Bonds",
|
||
.tax_pct = null,
|
||
.weight = 0.05,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
};
|
||
const line = try formatRow(arena, theme.default_theme, r);
|
||
try testing.expect(std.mem.count(u8, line.text, "—") >= 8);
|
||
}
|
||
|
||
test "formatRow: abbreviates Communication Services" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const r: review_view.ReviewRow = .{
|
||
.symbol = "GOOGL",
|
||
.sector_mid = "Communication Services",
|
||
.tax_pct = null,
|
||
.weight = 0.05,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
};
|
||
const line = try formatRow(arena, theme.default_theme, r);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "Comm. Services") != null);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "Communication Services") == null);
|
||
}
|
||
|
||
test "formatTotalsRow: contains Total label and weight" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const t: review_view.ReviewTotals = .{
|
||
.weight = 1.0,
|
||
.return_1y = 0.20,
|
||
.return_3y = 0.15,
|
||
.return_5y = 0.13,
|
||
.return_10y = 0.14,
|
||
.vol_3y = 0.13,
|
||
.vol_10y = 0.16,
|
||
.sharpe_3y = 1.05,
|
||
.sharpe_10y = 0.95,
|
||
.maxdd_5y = 0.22,
|
||
.tax_pct = 0.50,
|
||
.reweight_flags = .{},
|
||
};
|
||
const line = try formatTotalsRow(arena, theme.default_theme, t);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "Total") != null);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "100.0%") != null);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "+20.0%") != null);
|
||
}
|
||
|
||
test "formatTotalsRow: reweight flag adds asterisk" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const t: review_view.ReviewTotals = .{
|
||
.weight = 1.0,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = 0.13,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
.tax_pct = null,
|
||
.reweight_flags = .{ .vol_3y = true },
|
||
};
|
||
const line = try formatTotalsRow(arena, theme.default_theme, t);
|
||
try testing.expect(std.mem.indexOf(u8, line.text, "13.0%*") != null);
|
||
}
|
||
|
||
test "applySort: explicit field replaces default grouping" {
|
||
var rows = [_]review_view.ReviewRow{
|
||
.{
|
||
.symbol = "AAPL",
|
||
.sector_mid = "Technology",
|
||
.tax_pct = null,
|
||
.weight = 0.05,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
},
|
||
.{
|
||
.symbol = "VTI",
|
||
.sector_mid = "Equity / Corporate",
|
||
.tax_pct = null,
|
||
.weight = 0.40,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
},
|
||
.{
|
||
.symbol = "MSFT",
|
||
.sector_mid = "Technology",
|
||
.tax_pct = null,
|
||
.weight = 0.15,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
},
|
||
};
|
||
|
||
const view: review_view.ReviewView = .{
|
||
.rows = rows[0..],
|
||
.totals = .{
|
||
.weight = 1.0,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
.tax_pct = null,
|
||
.reweight_flags = .{},
|
||
},
|
||
.as_of = zfin.Date.fromYmd(2026, 6, 4),
|
||
.total_liquid = 0,
|
||
.portfolio_path = "",
|
||
};
|
||
var state: State = .{ .view = view, .sort_field = .sector, .sort_dir = .asc };
|
||
applySort(&state);
|
||
// Default grouping (sector asc with symbol-asc tiebreaker) →
|
||
// Equity/Corporate first (only VTI), then Technology with
|
||
// AAPL before MSFT (alphabetical).
|
||
try testing.expectEqualStrings("Equity / Corporate", state.view.?.rows[0].sector_mid);
|
||
try testing.expectEqualStrings("VTI", state.view.?.rows[0].symbol);
|
||
try testing.expectEqualStrings("Technology", state.view.?.rows[1].sector_mid);
|
||
try testing.expectEqualStrings("AAPL", state.view.?.rows[1].symbol);
|
||
try testing.expectEqualStrings("Technology", state.view.?.rows[2].sector_mid);
|
||
try testing.expectEqualStrings("MSFT", state.view.?.rows[2].symbol);
|
||
|
||
// Explicit weight desc
|
||
state.sort_field = .weight;
|
||
state.sort_dir = .desc;
|
||
applySort(&state);
|
||
try testing.expectEqualStrings("VTI", state.view.?.rows[0].symbol);
|
||
try testing.expectEqualStrings("MSFT", state.view.?.rows[1].symbol);
|
||
try testing.expectEqualStrings("AAPL", state.view.?.rows[2].symbol);
|
||
}
|
||
|
||
test "applyHeaderClick: prefix click does not modify state" {
|
||
var state: State = .{ .sort_field = .sector, .sort_dir = .asc };
|
||
try testing.expect(!applyHeaderClick(&state, 0));
|
||
try testing.expect(!applyHeaderClick(&state, 1));
|
||
// State unchanged.
|
||
try testing.expectEqual(review_view.SortField.sector, state.sort_field);
|
||
try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir);
|
||
}
|
||
|
||
test "applyHeaderClick: gap-column click does not modify state" {
|
||
var state: State = .{ .sort_field = .sector, .sort_dir = .asc };
|
||
// Symbol cell ends at col 9 (prefix=2, width=8). Gap is col 10.
|
||
try testing.expect(!applyHeaderClick(&state, 10));
|
||
try testing.expectEqual(review_view.SortField.sector, state.sort_field);
|
||
}
|
||
|
||
test "applyHeaderClick: click on Symbol column sets sort to symbol with default asc" {
|
||
var state: State = .{ .sort_field = .weight, .sort_dir = .desc };
|
||
// Symbol occupies cols 2..9.
|
||
try testing.expect(applyHeaderClick(&state, 2));
|
||
try testing.expectEqual(review_view.SortField.symbol, state.sort_field);
|
||
try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir);
|
||
}
|
||
|
||
test "applyHeaderClick: click on numeric column sets sort with default desc" {
|
||
var state: State = .{ .sort_field = .symbol, .sort_dir = .asc };
|
||
// Weight column starts at col 32 (see hitTestHeader test).
|
||
try testing.expect(applyHeaderClick(&state, 32));
|
||
try testing.expectEqual(review_view.SortField.weight, state.sort_field);
|
||
try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir);
|
||
}
|
||
|
||
test "applyHeaderClick: clicking active column flips direction" {
|
||
var state: State = .{ .sort_field = .weight, .sort_dir = .desc };
|
||
// Re-click weight column.
|
||
try testing.expect(applyHeaderClick(&state, 32));
|
||
try testing.expectEqual(review_view.SortField.weight, state.sort_field);
|
||
try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir);
|
||
// Click again — flip back.
|
||
try testing.expect(applyHeaderClick(&state, 32));
|
||
try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir);
|
||
}
|
||
|
||
test "handleAction: sort_col_next walks through fields" {
|
||
var state: State = .{ .sort_field = .symbol, .sort_dir = .asc };
|
||
// handleAction takes *App but never reads it; pass undefined.
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .sort_col_next);
|
||
try testing.expectEqual(review_view.SortField.sector, state.sort_field);
|
||
}
|
||
|
||
test "handleAction: sort_col_prev walks backward" {
|
||
var state: State = .{ .sort_field = .sector, .sort_dir = .asc };
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .sort_col_prev);
|
||
try testing.expectEqual(review_view.SortField.symbol, state.sort_field);
|
||
}
|
||
|
||
test "handleAction: sort_reverse flips direction" {
|
||
var state: State = .{ .sort_field = .sector, .sort_dir = .asc };
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .sort_reverse);
|
||
try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir);
|
||
tab.handleAction(&state, &app, .sort_reverse);
|
||
try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir);
|
||
}
|
||
|
||
test "deinitState: cleans up view (leak check)" {
|
||
// Allocate a real ReviewView. deinitState must free everything
|
||
// for testing.allocator to pass.
|
||
const Date = zfin.Date;
|
||
|
||
var allocs = [_]@import("../analytics/valuation.zig").Allocation{
|
||
.{
|
||
.symbol = "VTI",
|
||
.display_symbol = "VTI",
|
||
.shares = 100,
|
||
.avg_cost = 200,
|
||
.current_price = 220,
|
||
.market_value = 22000,
|
||
.cost_basis = 20000,
|
||
.weight = 1.0,
|
||
.unrealized_gain_loss = 2000,
|
||
.unrealized_return = 0.10,
|
||
},
|
||
};
|
||
const summary: @import("../analytics/valuation.zig").PortfolioSummary = .{
|
||
.total_value = 22000,
|
||
.total_cost = 20000,
|
||
.unrealized_gain_loss = 2000,
|
||
.unrealized_return = 0.10,
|
||
.realized_gain_loss = 0,
|
||
.allocations = allocs[0..],
|
||
};
|
||
var lots = [_]zfin.Lot{
|
||
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200 },
|
||
};
|
||
const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator };
|
||
var class_entries = [_]zfin.classification.ClassificationEntry{
|
||
.{ .symbol = "VTI", .sector = "Equity / Corporate", .pct = 100.0 },
|
||
};
|
||
const cm: zfin.classification.ClassificationMap = .{
|
||
.entries = class_entries[0..],
|
||
.allocator = testing.allocator,
|
||
};
|
||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||
defer candle_map.deinit();
|
||
|
||
const view = try review_view.buildReview(
|
||
testing.allocator,
|
||
std.testing.io,
|
||
summary,
|
||
&candle_map,
|
||
null,
|
||
portfolio,
|
||
cm,
|
||
null,
|
||
Date.fromYmd(2026, 6, 4),
|
||
"test.srf",
|
||
);
|
||
|
||
// Build a dividend map with one allocated entry — but DO NOT
|
||
// attach it to State (dividend_map lives on App now). Free it
|
||
// after deinitState so the test's testing.allocator stays
|
||
// satisfied.
|
||
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(testing.allocator);
|
||
const divs = try testing.allocator.alloc(zfin.Dividend, 1);
|
||
divs[0] = .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 1.5 };
|
||
try dividend_map.put("VTI", divs);
|
||
|
||
var state: State = .{
|
||
.loaded = true,
|
||
.view = view,
|
||
.classification_map = null, // owned by caller in this test
|
||
.sort_field = .sector,
|
||
.sort_dir = .asc,
|
||
};
|
||
|
||
// Single deinit must free everything State owns.
|
||
deinitState(&state, testing.allocator);
|
||
try testing.expect(state.view == null);
|
||
|
||
// Manually clean up our standalone dividend_map (not owned by
|
||
// State — App owns these in production).
|
||
testing.allocator.free(divs);
|
||
dividend_map.deinit();
|
||
}
|
||
|
||
// ── Step 8a observations integration tests ─────────────────────
|
||
|
||
test "severityGlyph: covers every severity" {
|
||
try testing.expect(severityGlyph(.warn).len > 0);
|
||
try testing.expect(severityGlyph(.flag).len > 0);
|
||
try testing.expect(severityGlyph(.err).len > 0);
|
||
}
|
||
|
||
test "appendFindingsSection: empty findings with no acked produces 'No findings.'" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
const fv: observations_view.FindingsView = .{
|
||
.rows = &.{},
|
||
.total_active = 0,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
};
|
||
var state: State = .{};
|
||
|
||
try appendFindingsSection(arena, &lines, &state, fv, theme.default_theme);
|
||
|
||
try testing.expect(lines.items.len >= 4);
|
||
var found_no_findings = false;
|
||
for (lines.items) |ln| {
|
||
if (std.mem.indexOf(u8, ln.text, "No findings.") != null) found_no_findings = true;
|
||
}
|
||
try testing.expect(found_no_findings);
|
||
}
|
||
|
||
test "appendFindingsSection: hidden-acked hint when all findings are acked" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
const fv: observations_view.FindingsView = .{
|
||
.rows = &.{},
|
||
.total_active = 0,
|
||
.total_acked = 3,
|
||
.total_resolved = 0,
|
||
};
|
||
var state: State = .{ .show_acked = false };
|
||
|
||
try appendFindingsSection(arena, &lines, &state, fv, theme.default_theme);
|
||
|
||
var hint_found = false;
|
||
for (lines.items) |ln| {
|
||
if (std.mem.indexOf(u8, ln.text, "Press 'v'") != null) hint_found = true;
|
||
}
|
||
try testing.expect(hint_found);
|
||
}
|
||
|
||
test "appendFindingsSection: renders cursor highlight on focused row" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
const rows = [_]observations_view.FindingRow{
|
||
.{ .severity = .warn, .kind = "k", .target = "t1", .text = "t1 finding", .is_acked = false },
|
||
.{ .severity = .flag, .kind = "k", .target = "t2", .text = "t2 finding", .is_acked = false },
|
||
};
|
||
const fv: observations_view.FindingsView = .{
|
||
.rows = @constCast(&rows),
|
||
.total_active = 2,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
};
|
||
var state: State = .{ .focus = .findings, .findings_cursor = 1 };
|
||
|
||
try appendFindingsSection(arena, &lines, &state, fv, theme.default_theme);
|
||
|
||
// Find the row containing "t1 finding" and "t2 finding"; t2's
|
||
// style should match selectStyle while t1's should not.
|
||
const sel = theme.default_theme.selectStyle();
|
||
var t1_style: ?vaxis.Style = null;
|
||
var t2_style: ?vaxis.Style = null;
|
||
for (lines.items) |ln| {
|
||
if (std.mem.indexOf(u8, ln.text, "t1 finding") != null) t1_style = ln.style;
|
||
if (std.mem.indexOf(u8, ln.text, "t2 finding") != null) t2_style = ln.style;
|
||
}
|
||
try testing.expect(t1_style != null and t2_style != null);
|
||
try testing.expect(std.meta.eql(t2_style.?, sel));
|
||
try testing.expect(!std.meta.eql(t1_style.?, sel));
|
||
}
|
||
|
||
test "clampCursor: forward delta clamps at len-1" {
|
||
try testing.expectEqual(@as(usize, 4), clampCursor(2, 5, 5));
|
||
try testing.expectEqual(@as(usize, 0), clampCursor(0, 0, 5));
|
||
try testing.expectEqual(@as(usize, 4), clampCursor(4, 1, 5));
|
||
}
|
||
|
||
test "clampCursor: backward delta clamps at 0" {
|
||
try testing.expectEqual(@as(usize, 0), clampCursor(2, -5, 5));
|
||
try testing.expectEqual(@as(usize, 0), clampCursor(0, -1, 5));
|
||
try testing.expectEqual(@as(usize, 1), clampCursor(2, -1, 5));
|
||
}
|
||
|
||
test "expansionLineCount: minimum is 1 (detail line)" {
|
||
const row: observations_view.FindingRow = .{
|
||
.severity = .warn,
|
||
.kind = "k",
|
||
.target = "t",
|
||
.text = "x",
|
||
.is_acked = false,
|
||
};
|
||
try testing.expectEqual(@as(usize, 1), expansionLineCount(row));
|
||
}
|
||
|
||
test "expansionLineCount: acked with two notes adds 1 + 2 = 3" {
|
||
const notes = [_][]const u8{ "first", "second" };
|
||
const entry = Journal.Entry{
|
||
.ack = .{
|
||
.observation = "k",
|
||
.target = "t",
|
||
.acknowledged_at = zfin.Date.fromYmd(2026, 1, 1),
|
||
.state = .acknowledged,
|
||
},
|
||
.notes = ¬es,
|
||
};
|
||
const row: observations_view.FindingRow = .{
|
||
.severity = .warn,
|
||
.kind = "k",
|
||
.target = "t",
|
||
.text = "x",
|
||
.is_acked = true,
|
||
.ack_entry = &entry,
|
||
};
|
||
// Detail (1) + ack date (1) + 2 notes = 4.
|
||
try testing.expectEqual(@as(usize, 4), expansionLineCount(row));
|
||
}
|
||
|
||
test "inputBarLineCount: zero when not in ack_note mode" {
|
||
const state: State = .{};
|
||
try testing.expectEqual(@as(usize, 0), inputBarLineCount(&state));
|
||
}
|
||
|
||
test "inputBarLineCount: one in-progress line + one hint when in ack_note with no fragments" {
|
||
const state: State = .{ .input_mode = .ack_note };
|
||
try testing.expectEqual(@as(usize, 2), inputBarLineCount(&state));
|
||
}
|
||
|
||
test "clearNoteFragments: idempotent on empty list" {
|
||
var state: State = .{};
|
||
clearNoteFragments(&state, testing.allocator);
|
||
clearNoteFragments(&state, testing.allocator);
|
||
try testing.expectEqual(@as(usize, 0), state.note_fragments.items.len);
|
||
}
|
||
|
||
test "clearNoteFragments: frees fragment slices" {
|
||
var state: State = .{};
|
||
const frag1 = try testing.allocator.dupe(u8, "first");
|
||
const frag2 = try testing.allocator.dupe(u8, "second");
|
||
try state.note_fragments.append(testing.allocator, frag1);
|
||
try state.note_fragments.append(testing.allocator, frag2);
|
||
|
||
clearNoteFragments(&state, testing.allocator);
|
||
try testing.expectEqual(@as(usize, 0), state.note_fragments.items.len);
|
||
}
|
||
|
||
// ── handleAction: new M2 actions (no App access) ───────────────
|
||
|
||
test "handleAction: focus_findings sets focus" {
|
||
var state: State = .{};
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .focus_findings);
|
||
try testing.expectEqual(FocusTarget.findings, state.focus);
|
||
}
|
||
|
||
test "handleAction: focus_holdings clears expansion" {
|
||
var state: State = .{ .focus = .findings, .expanded_finding = 2 };
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .focus_holdings);
|
||
try testing.expectEqual(FocusTarget.holdings, state.focus);
|
||
try testing.expect(state.expanded_finding == null);
|
||
}
|
||
|
||
test "handleAction: toggle_expand on findings cursor toggles expanded_finding" {
|
||
// Build a minimal findings_view with one row so toggle_expand
|
||
// has something to expand to.
|
||
const rows = [_]observations_view.FindingRow{
|
||
.{ .severity = .warn, .kind = "k", .target = "t", .text = "x", .is_acked = false },
|
||
};
|
||
var state: State = .{
|
||
.focus = .findings,
|
||
.findings_cursor = 0,
|
||
.findings_view = .{
|
||
.rows = @constCast(&rows),
|
||
.total_active = 1,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
|
||
tab.handleAction(&state, &app, .toggle_expand);
|
||
try testing.expect(state.expanded_finding != null);
|
||
try testing.expectEqual(@as(usize, 0), state.expanded_finding.?);
|
||
|
||
tab.handleAction(&state, &app, .toggle_expand);
|
||
try testing.expect(state.expanded_finding == null);
|
||
|
||
// findings_view is borrowed in the test (rows are stack-allocated),
|
||
// so we must clear the field before deinit() to avoid a double-free.
|
||
state.findings_view = null;
|
||
}
|
||
|
||
test "handleAction: toggle_expand on empty findings is no-op" {
|
||
var state: State = .{
|
||
.focus = .findings,
|
||
.findings_view = .{
|
||
.rows = &.{},
|
||
.total_active = 0,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .toggle_expand);
|
||
try testing.expect(state.expanded_finding == null);
|
||
}
|
||
|
||
test "handleAction: toggle_expand from holdings focus is no-op" {
|
||
var state: State = .{ .focus = .holdings };
|
||
var app: App = undefined;
|
||
tab.handleAction(&state, &app, .toggle_expand);
|
||
try testing.expect(state.expanded_finding == null);
|
||
}
|
||
|
||
// ── onCursorMove: routes to focused table ──────────────────────
|
||
|
||
test "onCursorMove: holdings focus moves holdings_cursor" {
|
||
// Build a minimal ReviewView with 5 rows.
|
||
const rows = try testing.allocator.alloc(review_view.ReviewRow, 5);
|
||
defer testing.allocator.free(rows);
|
||
for (rows, 0..) |*r, i| {
|
||
r.* = .{
|
||
.symbol = "X",
|
||
.sector_mid = "Other",
|
||
.tax_pct = 0,
|
||
.weight = 0.2,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
};
|
||
_ = i;
|
||
}
|
||
var state: State = .{
|
||
.focus = .holdings,
|
||
.holdings_cursor = 0,
|
||
.view = .{
|
||
.rows = rows,
|
||
.totals = std.mem.zeroes(review_view.ReviewTotals),
|
||
.as_of = zfin.Date.fromYmd(2026, 6, 8),
|
||
.total_liquid = 0,
|
||
.portfolio_path = "x",
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
try testing.expect(tab.onCursorMove(&state, &app, 2));
|
||
try testing.expectEqual(@as(usize, 2), state.holdings_cursor);
|
||
try testing.expect(tab.onCursorMove(&state, &app, 100)); // clamps
|
||
try testing.expectEqual(@as(usize, 4), state.holdings_cursor);
|
||
|
||
// Borrowed view; clear before drop to avoid double-free.
|
||
state.view = null;
|
||
}
|
||
|
||
test "onCursorMove: findings focus moves findings_cursor and clears stale expansion" {
|
||
const rows = [_]observations_view.FindingRow{
|
||
.{ .severity = .warn, .kind = "k", .target = "t1", .text = "x", .is_acked = false },
|
||
.{ .severity = .warn, .kind = "k", .target = "t2", .text = "x", .is_acked = false },
|
||
.{ .severity = .warn, .kind = "k", .target = "t3", .text = "x", .is_acked = false },
|
||
};
|
||
var state: State = .{
|
||
.focus = .findings,
|
||
.findings_cursor = 0,
|
||
.expanded_finding = 0,
|
||
.findings_view = .{
|
||
.rows = @constCast(&rows),
|
||
.total_active = 3,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
try testing.expect(tab.onCursorMove(&state, &app, 1));
|
||
try testing.expectEqual(@as(usize, 1), state.findings_cursor);
|
||
try testing.expect(state.expanded_finding == null); // moved off, expansion collapsed
|
||
|
||
state.findings_view = null;
|
||
}
|
||
|
||
test "onCursorMove: empty table returns false" {
|
||
var state: State = .{ .focus = .findings, .findings_view = .{
|
||
.rows = &.{},
|
||
.total_active = 0,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
} };
|
||
var app: App = undefined;
|
||
try testing.expect(!tab.onCursorMove(&state, &app, 1));
|
||
}
|
||
|
||
// ── onWheelMove: always returns false ──────────────────────────
|
||
|
||
test "onWheelMove: always returns false" {
|
||
var state: State = .{};
|
||
var app: App = undefined;
|
||
try testing.expect(!tab.onWheelMove(&state, &app, 1));
|
||
try testing.expect(!tab.onWheelMove(&state, &app, -3));
|
||
}
|
||
|
||
// ── handleMouse: holdings & findings click translates to cursor ─
|
||
|
||
test "handleMouse: click on holdings row sets cursor + focus" {
|
||
const rows = try testing.allocator.alloc(review_view.ReviewRow, 3);
|
||
defer testing.allocator.free(rows);
|
||
for (rows, 0..) |*r, i| {
|
||
r.* = .{
|
||
.symbol = "X",
|
||
.sector_mid = "S",
|
||
.tax_pct = 0,
|
||
.weight = 0.33,
|
||
.return_1y = null,
|
||
.return_3y = null,
|
||
.return_5y = null,
|
||
.return_10y = null,
|
||
.vol_3y = null,
|
||
.vol_10y = null,
|
||
.sharpe_3y = null,
|
||
.sharpe_10y = null,
|
||
.maxdd_5y = null,
|
||
};
|
||
_ = i;
|
||
}
|
||
var state: State = .{
|
||
.focus = .findings,
|
||
.holdings_first_row = 5,
|
||
.header_row = 4,
|
||
.view = .{
|
||
.rows = rows,
|
||
.totals = std.mem.zeroes(review_view.ReviewTotals),
|
||
.as_of = zfin.Date.fromYmd(2026, 6, 8),
|
||
.total_liquid = 0,
|
||
.portfolio_path = "x",
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
app.scroll_offset = 0;
|
||
|
||
const consumed = tab.handleMouse(&state, &app, .{
|
||
.button = .left,
|
||
.type = .press,
|
||
.row = 6, // content_row 6 = holdings_first_row+1 → cursor 1
|
||
.col = 0,
|
||
.mods = .{},
|
||
});
|
||
try testing.expect(consumed);
|
||
try testing.expectEqual(FocusTarget.holdings, state.focus);
|
||
try testing.expectEqual(@as(usize, 1), state.holdings_cursor);
|
||
|
||
state.view = null; // borrowed slice; avoid double-free
|
||
}
|
||
|
||
test "handleMouse: click on findings row sets cursor + focus" {
|
||
const rows = [_]observations_view.FindingRow{
|
||
.{ .severity = .warn, .kind = "k", .target = "t1", .text = "x", .is_acked = false },
|
||
.{ .severity = .warn, .kind = "k", .target = "t2", .text = "x", .is_acked = false },
|
||
};
|
||
// handleMouse early-returns if state.view is null. Give it an
|
||
// empty ReviewView so the findings-click branch is reachable.
|
||
const empty_view: review_view.ReviewView = .{
|
||
.rows = &.{},
|
||
.totals = std.mem.zeroes(review_view.ReviewTotals),
|
||
.as_of = zfin.Date.fromYmd(2026, 6, 8),
|
||
.total_liquid = 0,
|
||
.portfolio_path = "x",
|
||
};
|
||
var state: State = .{
|
||
.focus = .holdings,
|
||
.view = empty_view,
|
||
.findings_first_row = 10,
|
||
.findings_row_count = 2,
|
||
.findings_view = .{
|
||
.rows = @constCast(&rows),
|
||
.total_active = 2,
|
||
.total_acked = 0,
|
||
.total_resolved = 0,
|
||
},
|
||
};
|
||
var app: App = undefined;
|
||
app.scroll_offset = 0;
|
||
|
||
const consumed = tab.handleMouse(&state, &app, .{
|
||
.button = .left,
|
||
.type = .press,
|
||
.row = 11, // → 2nd finding
|
||
.col = 0,
|
||
.mods = .{},
|
||
});
|
||
try testing.expect(consumed);
|
||
try testing.expectEqual(FocusTarget.findings, state.focus);
|
||
try testing.expectEqual(@as(usize, 1), state.findings_cursor);
|
||
|
||
state.view = null;
|
||
state.findings_view = null;
|
||
}
|
||
|
||
test "handleMouse: non-press / non-left events ignored" {
|
||
var state: State = .{};
|
||
var app: App = undefined;
|
||
app.scroll_offset = 0;
|
||
|
||
try testing.expect(!tab.handleMouse(&state, &app, .{
|
||
.button = .right,
|
||
.type = .press,
|
||
.row = 0,
|
||
.col = 0,
|
||
.mods = .{},
|
||
}));
|
||
try testing.expect(!tab.handleMouse(&state, &app, .{
|
||
.button = .left,
|
||
.type = .release,
|
||
.row = 0,
|
||
.col = 0,
|
||
.mods = .{},
|
||
}));
|
||
}
|
||
|
||
// ── status-grid tests ─────────────────────────────────────────
|
||
|
||
test "checkStatusGlyph: covers every CheckResult variant" {
|
||
try testing.expect(checkStatusGlyph(.pass).len > 0);
|
||
try testing.expect(checkStatusGlyph(.{ .warn = &.{} }).len > 0);
|
||
try testing.expect(checkStatusGlyph(.{ .flag = &.{} }).len > 0);
|
||
try testing.expect(checkStatusGlyph(.skipped).len > 0);
|
||
try testing.expect(checkStatusGlyph(.{ .err = "" }).len > 0);
|
||
}
|
||
|
||
test "checkStatusGlyph: distinct glyph per result" {
|
||
// The whole point of the grid is that users can disambiguate
|
||
// states at a glance — no two results should map to the same
|
||
// glyph.
|
||
const pass_g = checkStatusGlyph(.pass);
|
||
const warn_g = checkStatusGlyph(.{ .warn = &.{} });
|
||
const flag_g = checkStatusGlyph(.{ .flag = &.{} });
|
||
const skipped_g = checkStatusGlyph(.skipped);
|
||
const err_g = checkStatusGlyph(.{ .err = "" });
|
||
try testing.expect(!std.mem.eql(u8, pass_g, warn_g));
|
||
try testing.expect(!std.mem.eql(u8, pass_g, flag_g));
|
||
try testing.expect(!std.mem.eql(u8, pass_g, skipped_g));
|
||
try testing.expect(!std.mem.eql(u8, pass_g, err_g));
|
||
try testing.expect(!std.mem.eql(u8, warn_g, flag_g));
|
||
try testing.expect(!std.mem.eql(u8, warn_g, skipped_g));
|
||
try testing.expect(!std.mem.eql(u8, warn_g, err_g));
|
||
try testing.expect(!std.mem.eql(u8, flag_g, skipped_g));
|
||
try testing.expect(!std.mem.eql(u8, flag_g, err_g));
|
||
try testing.expect(!std.mem.eql(u8, skipped_g, err_g));
|
||
}
|
||
|
||
test "appendStatusGrid: one row per status_cells_per_row checks" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
// Build a 6-check panel by hand. Pass, warn, flag, skipped,
|
||
// err, pass — covers every glyph.
|
||
const checks = [_]observations.Check{
|
||
.{
|
||
.name = "a",
|
||
.label = "Alpha",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
.{
|
||
.name = "b",
|
||
.label = "Bravo",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
.{
|
||
.name = "c",
|
||
.label = "Charlie",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
.{
|
||
.name = "d",
|
||
.label = "Delta",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
.{
|
||
.name = "e",
|
||
.label = "Echo",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
.{
|
||
.name = "f",
|
||
.label = "Foxtrot",
|
||
// SAFETY: test-only Check; pre-baked CheckResults bypass dispatch.
|
||
.run = undefined,
|
||
},
|
||
};
|
||
var pending = [_]observations.PendingCheck{
|
||
.{ .check = &checks[0], .state = .{ .complete = .pass } },
|
||
.{ .check = &checks[1], .state = .{ .complete = .{ .warn = &.{} } } },
|
||
.{ .check = &checks[2], .state = .{ .complete = .{ .flag = &.{} } } },
|
||
.{ .check = &checks[3], .state = .{ .complete = .skipped } },
|
||
.{ .check = &checks[4], .state = .{ .complete = .{ .err = "" } } },
|
||
.{ .check = &checks[5], .state = .{ .complete = .pass } },
|
||
};
|
||
const panel: observations.CheckPanel = .{
|
||
.allocator = testing.allocator,
|
||
.pending = &pending,
|
||
};
|
||
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
try appendStatusGrid(arena, &lines, panel, theme.default_theme);
|
||
|
||
// 6 cells / 3 per row = 2 rows.
|
||
try testing.expectEqual(@as(usize, 2), lines.items.len);
|
||
|
||
// First row should contain Alpha + Bravo + Charlie labels.
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Alpha") != null);
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Bravo") != null);
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Charlie") != null);
|
||
// Second row: Delta + Echo + Foxtrot.
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "Delta") != null);
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "Echo") != null);
|
||
try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "Foxtrot") != null);
|
||
}
|
||
|
||
test "appendStatusGrid: empty panel produces no lines" {
|
||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||
defer arena_state.deinit();
|
||
const arena = arena_state.allocator();
|
||
|
||
const panel: observations.CheckPanel = .{
|
||
.allocator = testing.allocator,
|
||
.pending = &.{},
|
||
};
|
||
var lines: std.ArrayList(StyledLine) = .empty;
|
||
try appendStatusGrid(arena, &lines, panel, theme.default_theme);
|
||
try testing.expectEqual(@as(usize, 0), lines.items.len);
|
||
}
|