zfin/src/tui/review_tab.zig

2645 lines
102 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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 = &notes,
};
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);
}