From 474d288c4ce6616adc8c429e6522d8b6fc7f23ae Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 9 Jun 2026 13:27:16 -0700 Subject: [PATCH] include observations in review tab --- src/commands/review.zig | 257 ++++- src/data/{journal.zig => Journal.zig} | 283 +++-- src/tui/review_tab.zig | 1441 ++++++++++++++++++++++++- src/views/observations_view.zig | 18 +- src/views/review.zig | 25 +- 5 files changed, 1845 insertions(+), 179 deletions(-) rename src/data/{journal.zig => Journal.zig} (74%) diff --git a/src/commands/review.zig b/src/commands/review.zig index ef7757d..0d594d3 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -13,11 +13,28 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("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 portfolio_risk = @import("../analytics/portfolio_risk.zig"); pub const ParsedArgs = struct { sort: ?review_view.SortField = null, sort_dir: review_view.SortDirection = .desc, + /// Whether to render acknowledged findings in the findings + /// table. Default false (active findings only). + show_acked: bool = false, + /// Which observation checks to run + display. `.all` runs every + /// registered check; `.fast` runs only short-running ones (none + /// in M2 — every check is fast). `.none` skips the engine + /// entirely (don't render the findings section). + checks: ChecksMode = .all, +}; + +pub const ChecksMode = enum { + all, + fast, + none, }; pub const meta: framework.Meta = .{ @@ -44,6 +61,11 @@ pub const meta: framework.Meta = .{ \\ 5y-maxdd \\ --asc Sort ascending (default: descending for \\ numeric fields, ascending for symbol/sector) + \\ --checks=MODE Observation engine mode: all (default), + \\ fast (skip long-running checks), none + \\ (suppress findings section). + \\ --show-acked Include already-acknowledged findings + \\ in the findings table. \\ \\Reads classifications from `metadata.srf` and account tax types \\from `accounts.srf`. Tax% is the share of each holding's market @@ -51,7 +73,7 @@ pub const meta: framework.Meta = .{ \\ , .uppercase_first_arg = false, - .user_errors = error{ UnexpectedArg, InvalidSortField }, + .user_errors = error{ UnexpectedArg, InvalidSortField, InvalidChecksMode }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { @@ -73,6 +95,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr parsed.sort_dir = .asc; } else if (std.mem.eql(u8, arg, "--desc")) { parsed.sort_dir = .desc; + } else if (std.mem.eql(u8, arg, "--show-acked")) { + parsed.show_acked = true; + } else if (std.mem.startsWith(u8, arg, "--checks=")) { + const value = arg["--checks=".len..]; + parsed.checks = parseChecksMode(value) orelse { + cli.stderrPrint(ctx.io, "Error: --checks must be one of: all, fast, none\n"); + return error.InvalidChecksMode; + }; } else { cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n"); return error.UnexpectedArg; @@ -81,6 +111,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr return parsed; } +fn parseChecksMode(s: []const u8) ?ChecksMode { + if (std.mem.eql(u8, s, "all")) return .all; + if (std.mem.eql(u8, s, "fast")) return .fast; + if (std.mem.eql(u8, s, "none")) return .none; + return null; +} + /// Comma-joined valid sort fields for the user-facing error message. /// Built once at comptime so we don't allocate at error time. const joined_sort_fields: []const u8 = blk: { @@ -173,6 +210,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { var view = try review_view.buildReview( allocator, + io, pf_data.summary, &pf_data.candle_map, ÷nd_map, @@ -191,7 +229,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { review_view.sortGroupedByDefault(view.rows); } - try render(out, color, view); + try render(allocator, io, out, color, view, anchor_path, parsed); } // ── Rendering ───────────────────────────────────────────────── @@ -227,7 +265,15 @@ const col_widths = [_]usize{ col_tax, }; -fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void { +fn render( + allocator: std.mem.Allocator, + io: std.Io, + out: *std.Io.Writer, + color: bool, + view: review_view.ReviewView, + anchor_path: []const u8, + parsed: ParsedArgs, +) !void { try cli.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" As of {f} Liquid: {f} Holdings: {d}\n\n", .{ @@ -235,6 +281,14 @@ fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void }); try cli.reset(out, color); + // Status grid: per-check pass/warn/flag glyphs across the top. + // Mirrors the TUI review tab so the "at a glance" experience + // matches when the user pipes the CLI output. + if (view.observations) |panel| { + try renderStatusGrid(out, color, panel); + try out.print("\n", .{}); + } + // Header row. Column order matches `col_widths`. try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" ", .{}); @@ -274,9 +328,164 @@ fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void try cli.reset(out, color); } + // Findings section. Render unless `--checks=none` was passed. + if (parsed.checks != .none) { + try renderFindings(allocator, io, out, color, &view, anchor_path, parsed.show_acked); + } + try out.print("\n", .{}); } +/// Render the per-check status grid to stdout. Layout mirrors +/// the TUI's `appendStatusGrid`: 3 cells per row, each cell +/// " ". Color promoted to the row's +/// worst severity so multi-cell rows still draw the user's eye +/// to the bad ones. +fn renderStatusGrid( + out: *std.Io.Writer, + color: bool, + panel: observations.CheckPanel, +) !void { + if (panel.pending.len == 0) return; + + const status_label_cols: usize = 22; + const cells_per_row: usize = 3; + + var i: usize = 0; + while (i < panel.pending.len) { + const end = @min(i + cells_per_row, panel.pending.len); + + // Row color = worst severity in the row. + var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err + var worst_color: [3]u8 = cli.CLR_MUTED; + 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; + worst_color = switch (result) { + .warn => cli.CLR_WARNING, + .flag, .err => cli.CLR_NEGATIVE, + else => cli.CLR_MUTED, + }; + } + } + try cli.setFg(out, color, worst_color); + + try out.print(" ", .{}); + for (panel.pending[i..end], 0..) |pc, col| { + if (col > 0) try out.print(" ", .{}); + + const label = pc.check.label; + const lbl_cols = label.len; // ASCII labels: byte count == display cols + + // Right-pad label. + if (lbl_cols < status_label_cols) { + var k: usize = 0; + while (k < status_label_cols - lbl_cols) : (k += 1) try out.print(" ", .{}); + } + try out.print("{s} ", .{label}); + + const glyph: []const u8 = switch (pc.state.complete) { + .pass => "✅\u{FE0F}", + .warn => "⚠️", + .flag => "❌\u{FE0F}", + .skipped => "➖\u{FE0F}", + .err => "🛑\u{FE0F}", + }; + try out.print("{s}", .{glyph}); + } + try out.print("\n", .{}); + try cli.reset(out, color); + + i = end; + } +} + +/// Render the findings section to stdout. Loads the journal from +/// the portfolio's directory (missing → empty), joins with the +/// observation panel via `observations_view.build`, and writes a +/// styled findings table similar to the TUI's. The CLI is read-only +/// — acks must come from the TUI. +fn renderFindings( + allocator: std.mem.Allocator, + io: std.Io, + out: *std.Io.Writer, + color: bool, + view: *const review_view.ReviewView, + anchor_path: []const u8, + show_acked: bool, +) !void { + const panel = if (view.observations) |*p| p else return; + + // Load the journal. Missing file ⇒ empty journal (first run). + const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const journal_path = try std.fmt.allocPrint(allocator, "{s}acknowledgments.srf", .{anchor_path[0..dir_end]}); + defer allocator.free(journal_path); + + var j = Journal.load(allocator, io, journal_path) catch |err| blk: { + cli.stderrPrint(io, "Warning: "); + cli.stderrPrint(io, journal_path); + cli.stderrPrint(io, ": "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, " — proceeding with empty journal.\n"); + const empty = try allocator.alloc(Journal.Entry, 0); + break :blk Journal{ .allocator = allocator, .entries = empty }; + }; + defer j.deinit(); + + var fv = try observations_view.build(allocator, panel, &j, show_acked); + defer fv.deinit(allocator); + + try out.print("\n", .{}); + try cli.setFg(out, color, cli.CLR_HEADER); + try out.print(" Findings ({d} active, {d} acked, {d} resolved)", .{ + fv.total_active, + fv.total_acked, + fv.total_resolved, + }); + if (show_acked) try out.print(" [showing acked]", .{}); + try out.print("\n", .{}); + try cli.reset(out, color); + try writeSeparator(out, color); + + if (fv.rows.len == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + if (fv.total_acked > 0 and !show_acked) { + try out.print(" No active findings. Use --show-acked to see acknowledged.\n", .{}); + } else { + try out.print(" No findings.\n", .{}); + } + try cli.reset(out, color); + return; + } + + for (fv.rows) |row| { + const glyph: []const u8 = switch (row.severity) { + .warn => "⚠️", + .flag => "❌\u{FE0F}", + .err => "🛑\u{FE0F}", + }; + const ansi: [3]u8 = if (row.is_acked) + cli.CLR_MUTED + else switch (row.severity) { + .warn => cli.CLR_WARNING, + .flag, .err => cli.CLR_NEGATIVE, + }; + try cli.setFg(out, color, ansi); + try out.print(" {s} {s}{s}\n", .{ + glyph, + if (row.is_acked) "[acked] " else "", + row.text, + }); + try cli.reset(out, color); + } +} + fn writeSeparator(out: *std.Io.Writer, color: bool) !void { try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" ", .{}); @@ -475,6 +684,44 @@ test "parseArgs: positional arg errors" { try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } +test "parseArgs: --show-acked sets the flag" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--show-acked"}; + const parsed = try parseArgs(&ctx, &args); + try testing.expect(parsed.show_acked); +} + +test "parseArgs: --checks=fast sets ChecksMode.fast" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--checks=fast"}; + const parsed = try parseArgs(&ctx, &args); + try testing.expectEqual(ChecksMode.fast, parsed.checks); +} + +test "parseArgs: --checks=none sets ChecksMode.none" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--checks=none"}; + const parsed = try parseArgs(&ctx, &args); + try testing.expectEqual(ChecksMode.none, parsed.checks); +} + +test "parseArgs: --checks=BOGUS errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--checks=bogus"}; + try testing.expectError(error.InvalidChecksMode, parseArgs(&ctx, &args)); +} + +test "parseChecksMode: covers all variants" { + try testing.expectEqual(ChecksMode.all, parseChecksMode("all").?); + try testing.expectEqual(ChecksMode.fast, parseChecksMode("fast").?); + try testing.expectEqual(ChecksMode.none, parseChecksMode("none").?); + try testing.expect(parseChecksMode("nope") == null); +} + test "joinSortFields: contains all field names" { const joined = joinSortFields(); for (review_view.sort_field_names) |name| { @@ -711,7 +958,7 @@ test "render: emits header, separator, rows, and totals" { .total_liquid = 1_000_000.0, .portfolio_path = "test_portfolio.srf", }; - try render(&w, false, view); + try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none }); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null); try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != null); @@ -748,7 +995,7 @@ test "render: emits reweight footnote when any flag set" { .total_liquid = 0, .portfolio_path = "x.srf", }; - try render(&w, false, view); + try render(testing.allocator, std.testing.io, &w, false, view, "test_portfolio.srf", .{ .checks = .none }); const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null); } diff --git a/src/data/journal.zig b/src/data/Journal.zig similarity index 74% rename from src/data/journal.zig rename to src/data/Journal.zig index 15e8d82..a6452f5 100644 --- a/src/data/journal.zig +++ b/src/data/Journal.zig @@ -55,11 +55,10 @@ //! ## Lifecycle //! //! - **Read:** single-pass iterator over the file. Acks push a new -//! `JournalEntry`; notes append to the last entry. Orphan note ⇒ +//! `Entry`; notes append to the last entry. Orphan note ⇒ //! `error.OrphanedNote`. //! - **Write:** `append` / `setState` mutate the in-memory `entries` and -//! atomic-rewrite the file (temp file + rename). Always rewriting keeps -//! the file clean; git tracks history. +//! atomic-rewrite the file via `atomic.writeFileAtomic`. //! - **Concurrency:** the file is per-portfolio (sibling of //! `portfolio.srf`); concurrent zfin invocations on the same portfolio //! would race, but that's the existing convention for every sibling @@ -70,6 +69,11 @@ const srf = @import("srf"); const Date = @import("../Date.zig"); const atomic = @import("../atomic.zig"); +const Journal = @This(); + +allocator: std.mem.Allocator, +entries: []Entry, + pub const State = enum { active, acknowledged, @@ -89,7 +93,7 @@ pub const Acknowledgment = struct { /// Wire-format note record: just a single line of text. Used only by /// the SRF parser/formatter; the rest of the codebase sees notes as -/// `[]const u8` slices on `JournalEntry.notes`. +/// `[]const u8` slices on `Entry.notes`. const NoteRecord = struct { line: []const u8, }; @@ -102,10 +106,9 @@ const JournalRecord = union(enum) { note: NoteRecord, }; -/// In-memory ack with its notes already grouped. Built by -/// `Journal.load`; consumed by callers that want "the ack and its -/// reasoning together." -pub const JournalEntry = struct { +/// In-memory ack with its notes already grouped. Built by `load`; +/// consumed by callers that want "the ack and its reasoning together." +pub const Entry = struct { ack: Acknowledgment, /// Note fragments in the order the user entered them. Each is an /// allocator-owned slice; freed by `Journal.deinit`. @@ -113,46 +116,40 @@ pub const JournalEntry = struct { /// Concatenate the notes with newlines into a single string. /// Allocator-owned; caller frees. - pub fn fullNote(self: JournalEntry, allocator: std.mem.Allocator) ![]u8 { + pub fn fullNote(self: Entry, allocator: std.mem.Allocator) ![]u8 { return try std.mem.join(allocator, "\n", self.notes); } }; -/// In-memory journal state. Owns all string data and the entries slice. -pub const Journal = struct { - allocator: std.mem.Allocator, - entries: []JournalEntry, - - pub fn deinit(self: *Journal) void { - const a = self.allocator; - for (self.entries) |entry| { - a.free(entry.ack.observation); - a.free(entry.ack.target); - for (entry.notes) |line| a.free(line); - a.free(entry.notes); - } - a.free(self.entries); - self.* = undefined; +pub fn deinit(self: *Journal) void { + const a = self.allocator; + for (self.entries) |entry| { + a.free(entry.ack.observation); + a.free(entry.ack.target); + for (entry.notes) |line| a.free(line); + a.free(entry.notes); } + a.free(self.entries); + self.* = undefined; +} - /// Find the entry matching `(observation, target)`. Returns null - /// if not found. There's only ever one entry per pair (we don't - /// preserve transition history), so no tiebreaker is needed. - pub fn findByTarget( - self: *const Journal, - observation: []const u8, - target: []const u8, - ) ?*const JournalEntry { - for (self.entries) |*e| { - if (!std.mem.eql(u8, e.ack.observation, observation)) continue; - if (!std.mem.eql(u8, e.ack.target, target)) continue; - return e; - } - return null; +/// Find the entry matching `(observation, target)`. Returns null if +/// not found. There's only ever one entry per pair (we don't +/// preserve transition history), so no tiebreaker is needed. +pub fn findByTarget( + self: *const Journal, + observation: []const u8, + target: []const u8, +) ?*const Entry { + for (self.entries) |*e| { + if (!std.mem.eql(u8, e.ack.observation, observation)) continue; + if (!std.mem.eql(u8, e.ack.target, target)) continue; + return e; } -}; + return null; +} -/// Load and parse a journal file. Returns an empty `Journal` when the +/// Load and parse a journal file. Returns an empty journal when the /// file doesn't exist (first-time use case). `path` should be an /// absolute or cwd-relative path to `acknowledgments.srf`. pub fn load( @@ -162,7 +159,7 @@ pub fn load( ) !Journal { const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) { error.FileNotFound => { - return .{ .allocator = allocator, .entries = try allocator.alloc(JournalEntry, 0) }; + return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0) }; }, else => return err, }; @@ -191,17 +188,17 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { if (data.len == 0) { return .{ .allocator = allocator, - .entries = try allocator.alloc(JournalEntry, 0), + .entries = try allocator.alloc(Entry, 0), }; } - var entries = std.ArrayList(JournalEntry).empty; + var entries = std.ArrayList(Entry).empty; errdefer freeEntries(allocator, &entries); // Per-entry notes lists. Lives parallel to `entries` and is // converted to owned slices at the end. We use a separate list // (instead of mutating each entry's `notes` field as we go) - // because `JournalEntry.notes` is `[]const []const u8` — a const + // because `Entry.notes` is `[]const []const u8` — a const // slice — so we can't append to it after the entry is created. var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty; errdefer { @@ -256,7 +253,7 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal { } /// Free a partially-built entries list (for parse errdefer). -fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntry)) void { +fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { for (entries.items) |entry| { allocator.free(entry.ack.observation); allocator.free(entry.ack.target); @@ -274,13 +271,13 @@ fn freeEntries(allocator: std.mem.Allocator, entries: *std.ArrayList(JournalEntr /// entered (one Enter press per fragment in the TUI's note input). /// Pass an empty slice to record an ack with no reasoning. pub fn append( - journal: *Journal, + self: *Journal, io: std.Io, path: []const u8, new_ack: Acknowledgment, note_fragments: []const []const u8, ) !void { - const a = journal.allocator; + const a = self.allocator; // Build the new entry's owned strings first. errdefer cleanup // is fiddly because we need to roll back partial allocations @@ -300,10 +297,10 @@ pub fn append( } // Grow the entries slice by one and append. - var new_entries = try a.alloc(JournalEntry, journal.entries.len + 1); + var new_entries = try a.alloc(Entry, self.entries.len + 1); errdefer a.free(new_entries); - @memcpy(new_entries[0..journal.entries.len], journal.entries); - new_entries[journal.entries.len] = .{ + @memcpy(new_entries[0..self.entries.len], self.entries); + new_entries[self.entries.len] = .{ .ack = .{ .observation = owned_obs, .target = owned_target, @@ -317,10 +314,10 @@ pub fn append( // Replace the slice WITHOUT freeing the old strings — they're // shallow-copied into new_entries above. Just free the old slice. - a.free(journal.entries); - journal.entries = new_entries; + a.free(self.entries); + self.entries = new_entries; - try writeFile(journal, io, path); + try writeFile(self, io, path); } /// Update the state of an existing acknowledgment, set the relevant @@ -335,7 +332,7 @@ pub fn append( /// Returns `error.AckNotFound` if no entry matches `(observation, /// target)`. pub fn setState( - journal: *Journal, + self: *Journal, io: std.Io, path: []const u8, observation: []const u8, @@ -343,8 +340,8 @@ pub fn setState( new_state: State, today: Date, ) !void { - var found: ?*JournalEntry = null; - for (journal.entries) |*e| { + var found: ?*Entry = null; + for (self.entries) |*e| { if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue; found = e; @@ -366,14 +363,14 @@ pub fn setState( } entry.ack.state = new_state; - try writeFile(journal, io, path); + try writeFile(self, io, path); } /// Atomic file write: serialize all records, then hand to /// `atomic.writeFileAtomic` which writes to `.tmp`, fsyncs, /// and renames. Crash-safe. -fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void { - const a = journal.allocator; +fn writeFile(self: *const Journal, io: std.Io, path: []const u8) !void { + const a = self.allocator; // Build the file contents in memory. For a journal of typical // size (dozens to low hundreds of records) this is trivially @@ -385,12 +382,12 @@ fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void { // `#!srfv1` directive header once at the top and emits every // record through its native formatter. Ack records are followed // immediately by their note records, in entry-list order. - var total_records: usize = journal.entries.len; - for (journal.entries) |e| total_records += e.notes.len; + var total_records: usize = self.entries.len; + for (self.entries) |e| total_records += e.notes.len; var records = try a.alloc(JournalRecord, total_records); defer a.free(records); var ri: usize = 0; - for (journal.entries) |e| { + for (self.entries) |e| { records[ri] = .{ .acknowledgment = e.ack }; ri += 1; for (e.notes) |line| { @@ -409,9 +406,9 @@ fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void { const testing = std.testing; test "parse: empty input produces empty journal" { - var j = try parse(testing.allocator, ""); - defer j.deinit(); - try testing.expectEqual(@as(usize, 0), j.entries.len); + var journal = try parse(testing.allocator, ""); + defer journal.deinit(); + try testing.expectEqual(@as(usize, 0), journal.entries.len); } test "parse: single ack with two notes round-trips" { @@ -421,10 +418,10 @@ test "parse: single ack with two notes round-trips" { \\type::note,line::Holding through earnings cycle. \\type::note,line::Will trim by Q3 2026. ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - try testing.expectEqual(@as(usize, 1), j.entries.len); - const entry = j.entries[0]; + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + try testing.expectEqual(@as(usize, 1), journal.entries.len); + const entry = journal.entries[0]; try testing.expectEqualStrings("position_concentration", entry.ack.observation); try testing.expectEqualStrings("NVDA", entry.ack.target); try testing.expectEqual(State.acknowledged, entry.ack.state); @@ -442,14 +439,14 @@ test "parse: notes attach to the most-recent preceding ack" { \\type::note,line::for B \\type::note,line::also for B ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - try testing.expectEqual(@as(usize, 2), j.entries.len); - try testing.expectEqual(@as(usize, 1), j.entries[0].notes.len); - try testing.expectEqualStrings("for A", j.entries[0].notes[0]); - try testing.expectEqual(@as(usize, 2), j.entries[1].notes.len); - try testing.expectEqualStrings("for B", j.entries[1].notes[0]); - try testing.expectEqualStrings("also for B", j.entries[1].notes[1]); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + try testing.expectEqual(@as(usize, 2), journal.entries.len); + try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len); + try testing.expectEqualStrings("for A", journal.entries[0].notes[0]); + try testing.expectEqual(@as(usize, 2), journal.entries[1].notes.len); + try testing.expectEqualStrings("for B", journal.entries[1].notes[0]); + try testing.expectEqualStrings("also for B", journal.entries[1].notes[1]); } test "parse: orphan note before any ack returns error.OrphanedNote" { @@ -467,17 +464,17 @@ test "findByTarget: finds matching, returns null for non-match" { \\type::acknowledgment,observation::position_concentration,target::NVDA,acknowledged_at::2026-06-12,state::acknowledged \\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged ; - var j = try parse(testing.allocator, data); - defer j.deinit(); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); - const found = j.findByTarget("position_concentration", "NVDA").?; + const found = journal.findByTarget("position_concentration", "NVDA").?; try testing.expectEqualStrings("NVDA", found.ack.target); - const not_found = j.findByTarget("position_concentration", "AAPL"); + const not_found = journal.findByTarget("position_concentration", "AAPL"); try testing.expect(not_found == null); } -test "JournalEntry.fullNote: joins fragments with newlines" { +test "Entry.fullNote: joins fragments with newlines" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active @@ -485,21 +482,21 @@ test "JournalEntry.fullNote: joins fragments with newlines" { \\type::note,line::second \\type::note,line::third ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - const full = try j.entries[0].fullNote(testing.allocator); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + const full = try journal.entries[0].fullNote(testing.allocator); defer testing.allocator.free(full); try testing.expectEqualStrings("first\nsecond\nthird", full); } -test "JournalEntry.fullNote: empty notes returns empty string" { +test "Entry.fullNote: empty notes returns empty string" { const data = \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - const full = try j.entries[0].fullNote(testing.allocator); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + const full = try journal.entries[0].fullNote(testing.allocator); defer testing.allocator.free(full); try testing.expectEqualStrings("", full); } @@ -509,10 +506,10 @@ test "parse: optional unacknowledged_at and resolved_at fields work when omitted \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - try testing.expect(j.entries[0].ack.unacknowledged_at == null); - try testing.expect(j.entries[0].ack.resolved_at == null); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + try testing.expect(journal.entries[0].ack.unacknowledged_at == null); + try testing.expect(journal.entries[0].ack.resolved_at == null); } test "parse: optional unacknowledged_at and resolved_at fields work when set" { @@ -520,10 +517,10 @@ test "parse: optional unacknowledged_at and resolved_at fields work when set" { \\#!srfv1 \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01 ; - var j = try parse(testing.allocator, data); - defer j.deinit(); - try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, j.entries[0].ack.unacknowledged_at.?.days); - try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, j.entries[0].ack.resolved_at.?.days); + var journal = try parse(testing.allocator, data); + defer journal.deinit(); + try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, journal.entries[0].ack.unacknowledged_at.?.days); + try testing.expectEqual(Date.fromYmd(2026, 12, 1).days, journal.entries[0].ack.resolved_at.?.days); } test "parse: malformed record returns parse error" { @@ -554,9 +551,9 @@ test "load: missing file returns empty journal" { const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); - try testing.expectEqual(@as(usize, 0), j.entries.len); + var journal = try load(allocator, io, path); + defer journal.deinit(); + try testing.expectEqual(@as(usize, 0), journal.entries.len); } test "append + load round-trip: ack with two notes" { @@ -570,27 +567,27 @@ test "append + load round-trip: ack with two notes" { const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); - try testing.expectEqual(@as(usize, 0), j.entries.len); + var journal = try load(allocator, io, path); + defer journal.deinit(); + try testing.expectEqual(@as(usize, 0), journal.entries.len); const fragments = [_][]const u8{ "first thought", "follow-up rationale" }; - try append(&j, io, path, .{ + try journal.append(io, path, .{ .observation = "position_concentration", .target = "NVDA", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &fragments); - var j2 = try load(allocator, io, path); - defer j2.deinit(); - try testing.expectEqual(@as(usize, 1), j2.entries.len); - try testing.expectEqualStrings("position_concentration", j2.entries[0].ack.observation); - try testing.expectEqualStrings("NVDA", j2.entries[0].ack.target); - try testing.expectEqual(State.acknowledged, j2.entries[0].ack.state); - try testing.expectEqual(@as(usize, 2), j2.entries[0].notes.len); - try testing.expectEqualStrings("first thought", j2.entries[0].notes[0]); - try testing.expectEqualStrings("follow-up rationale", j2.entries[0].notes[1]); + var reloaded = try load(allocator, io, path); + defer reloaded.deinit(); + try testing.expectEqual(@as(usize, 1), reloaded.entries.len); + try testing.expectEqualStrings("position_concentration", reloaded.entries[0].ack.observation); + try testing.expectEqualStrings("NVDA", reloaded.entries[0].ack.target); + try testing.expectEqual(State.acknowledged, reloaded.entries[0].ack.state); + try testing.expectEqual(@as(usize, 2), reloaded.entries[0].notes.len); + try testing.expectEqualStrings("first thought", reloaded.entries[0].notes[0]); + try testing.expectEqualStrings("follow-up rationale", reloaded.entries[0].notes[1]); } test "append: two acks land in append-order on reload" { @@ -604,27 +601,27 @@ test "append: two acks land in append-order on reload" { const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); + var journal = try load(allocator, io, path); + defer journal.deinit(); - try append(&j, io, path, .{ + try journal.append(io, path, .{ .observation = "k", .target = "B", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); - try append(&j, io, path, .{ + try journal.append(io, path, .{ .observation = "k", .target = "A", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); - var j2 = try load(allocator, io, path); - defer j2.deinit(); - try testing.expectEqual(@as(usize, 2), j2.entries.len); - try testing.expectEqualStrings("B", j2.entries[0].ack.target); - try testing.expectEqualStrings("A", j2.entries[1].ack.target); + var reloaded = try load(allocator, io, path); + defer reloaded.deinit(); + try testing.expectEqual(@as(usize, 2), reloaded.entries.len); + try testing.expectEqualStrings("B", reloaded.entries[0].ack.target); + try testing.expectEqualStrings("A", reloaded.entries[1].ack.target); } test "setState: acknowledged → active sets unacknowledged_at" { @@ -638,23 +635,23 @@ test "setState: acknowledged → active sets unacknowledged_at" { const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); + var journal = try load(allocator, io, path); + defer journal.deinit(); - try append(&j, io, path, .{ + try journal.append(io, path, .{ .observation = "k", .target = "X", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); - try setState(&j, io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9)); + try journal.setState(io, path, "k", "X", .active, Date.fromYmd(2026, 6, 9)); - var j2 = try load(allocator, io, path); - defer j2.deinit(); - try testing.expectEqual(State.active, j2.entries[0].ack.state); - try testing.expect(j2.entries[0].ack.unacknowledged_at != null); - try testing.expect(j2.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9))); + var reloaded = try load(allocator, io, path); + defer reloaded.deinit(); + try testing.expectEqual(State.active, reloaded.entries[0].ack.state); + try testing.expect(reloaded.entries[0].ack.unacknowledged_at != null); + try testing.expect(reloaded.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9))); } test "setState: missing target returns AckNotFound" { @@ -668,12 +665,12 @@ test "setState: missing target returns AckNotFound" { const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); + var journal = try load(allocator, io, path); + defer journal.deinit(); try testing.expectError( error.AckNotFound, - setState(&j, io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)), + journal.setState(io, path, "k", "missing", .resolved, Date.fromYmd(2026, 6, 8)), ); } @@ -688,21 +685,21 @@ test "setState: → resolved sets resolved_at" { const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path}); defer allocator.free(path); - var j = try load(allocator, io, path); - defer j.deinit(); + var journal = try load(allocator, io, path); + defer journal.deinit(); - try append(&j, io, path, .{ + try journal.append(io, path, .{ .observation = "k", .target = "X", .acknowledged_at = Date.fromYmd(2026, 6, 8), .state = .acknowledged, }, &.{}); - try setState(&j, io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10)); + try journal.setState(io, path, "k", "X", .resolved, Date.fromYmd(2026, 6, 10)); - var j2 = try load(allocator, io, path); - defer j2.deinit(); - try testing.expectEqual(State.resolved, j2.entries[0].ack.state); - try testing.expect(j2.entries[0].ack.resolved_at != null); - try testing.expect(j2.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10))); + var reloaded = try load(allocator, io, path); + defer reloaded.deinit(); + try testing.expectEqual(State.resolved, reloaded.entries[0].ack.state); + try testing.expect(reloaded.entries[0].ack.resolved_at != null); + try testing.expect(reloaded.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10))); } diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index 2fe6363..9ac0875 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -22,6 +22,10 @@ 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 service = @import("../service.zig"); @@ -38,6 +42,38 @@ pub const Action = enum { 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 ───────────────────────────────────────── @@ -66,6 +102,68 @@ pub const State = struct { /// `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 ──────────────────────────────────── @@ -76,27 +174,49 @@ pub const meta: framework.TabMeta(Action) = .{ .{ .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_reverse, }, }; /// 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, .tax_pct, .weight, - .return_1y, .return_3y, .return_5y, .return_10y, - .vol_3y, .vol_10y, .sharpe_3y, .sharpe_10y, - .maxdd_5y, + .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 { @@ -115,6 +235,9 @@ pub const tab = struct { 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); } @@ -124,14 +247,18 @@ pub const tab = struct { /// 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. + /// 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; freeDividendMap(state, app); state.loaded = false; if (app.portfolio.account_map) |*am| am.deinit(); app.portfolio.account_map = null; + loadJournal(state, app); loadData(state, app); } @@ -151,8 +278,29 @@ pub const tab = struct { 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); + }, } - _ = app; } /// Review requires a loaded portfolio file and per-symbol @@ -166,18 +314,155 @@ pub const tab = struct { /// 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). Wheel events fall through to App's - /// scroll handling. Returns true when consumed. + /// 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; - if (state.view == null) return false; + const view = state.view orelse return false; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; - if (content_row != state.header_row) return false; - const click_col: usize = @intCast(mouse.col); - if (!applyHeaderClick(state, click_col)) return false; - applySort(state); - return true; + + // 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 + } } }; @@ -231,6 +516,160 @@ fn applySort(state: *State) void { 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 { @@ -299,6 +738,7 @@ fn loadData(state: *State, app: *App) void { if (state.view) |*v| v.deinit(app.allocator); state.view = review_view.buildReview( app.allocator, + app.io, summary, &candle_map, if (state.dividend_map) |*dm| dm else null, @@ -313,6 +753,85 @@ fn loadData(state: *State, app: *App) void { }; 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; + } + } } fn freeDividendMap(state: *State, app: *App) void { @@ -323,7 +842,11 @@ fn freeDividendMap(state: *State, app: *App) void { /// 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); freeDividendMapWithAllocator(state, allocator); state.* = .{}; } @@ -586,6 +1109,15 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c }); 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. @@ -603,6 +1135,7 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c }); // Rows. + state.holdings_first_row = lines.items.len; for (view.rows) |r| { try lines.append(arena, try formatRow(arena, th, r)); } @@ -625,9 +1158,292 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c }); } + // 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 +/// " ". 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: +/// +/// +/// Findings (N active, M acked) +/// +/// ← per row +/// ← 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 `▲`/`▼` @@ -674,6 +1490,29 @@ fn buildSeparatorLine(arena: std.mem.Allocator, th: theme.Theme) !StyledLine { 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; @@ -818,15 +1657,18 @@ 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, wraps to the first. - try testing.expectEqual(review_view.SortField.symbol, nextSortField(.maxdd_5y)); + // 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" { - try testing.expectEqual(review_view.SortField.symbol, nextSortField(.maxdd_5y)); - // .symbol is first; prev wraps to last (.maxdd_5y). - try testing.expectEqual(review_view.SortField.maxdd_5y, prevSortField(.symbol)); + // .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" { @@ -1219,6 +2061,7 @@ test "deinitState: cleans up view + classification_map + dividend_map (leak chec const view = try review_view.buildReview( testing.allocator, + std.testing.io, summary, &candle_map, null, @@ -1252,3 +2095,561 @@ test "deinitState: cleans up view + classification_map + dividend_map (leak chec try testing.expect(state.view == null); try testing.expect(state.dividend_map == null); } + +// ── 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); +} diff --git a/src/views/observations_view.zig b/src/views/observations_view.zig index da7497b..6d47d5b 100644 --- a/src/views/observations_view.zig +++ b/src/views/observations_view.zig @@ -8,11 +8,11 @@ //! - The **engine** (`analytics/observations.zig`) produces a //! `CheckPanel` whose `pending` slice has one entry per registered //! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`. -//! - The **journal** (`data/journal.zig`) holds the user's +//! - The **journal** (`data/Journal.zig`) holds the user's //! acknowledgments — durable records keyed by `(observation, target)`. //! - This **view** matches the two: every `Observation` in a //! `warn`/`flag` result becomes a `FindingRow`. The row is marked -//! acked iff a `JournalEntry` exists with `state == .acknowledged` +//! acked iff a `Journal.Entry` exists with `state == .acknowledged` //! and matching `(observation, target)`. //! //! ## Lifetime @@ -32,13 +32,11 @@ const std = @import("std"); const observations = @import("../analytics/observations.zig"); -const journal_mod = @import("../data/journal.zig"); +const Journal = @import("../data/Journal.zig"); const Observation = observations.Observation; const Severity = observations.Severity; const CheckPanel = observations.CheckPanel; -const Journal = journal_mod.Journal; -const JournalEntry = journal_mod.JournalEntry; /// One displayable finding. Borrows all string data from the /// underlying `Observation` and (optionally) the matching journal @@ -57,7 +55,7 @@ pub const FindingRow = struct { /// The matching journal entry (when `is_acked == true`), so /// renderers can pull notes/acknowledged_at out without re-doing /// the lookup. Null when no ack matches. - ack_entry: ?*const JournalEntry = null, + ack_entry: ?*const Journal.Entry = null, }; /// Result of `build`. Owns only the `rows` slice; all string data @@ -153,7 +151,7 @@ pub fn build( /// Linear scan is fine for realistic journal sizes (tens of entries). /// If a portfolio ever accumulates hundreds of acks we can index by /// (observation, target) at load time. -fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const JournalEntry { +fn findAck(journal: *const Journal, observation: []const u8, target: []const u8) ?*const Journal.Entry { for (journal.entries) |*e| { if (e.ack.state != .acknowledged) continue; if (!std.mem.eql(u8, e.ack.observation, observation)) continue; @@ -217,12 +215,12 @@ fn makeJournalWithAck( allocator: std.mem.Allocator, observation: []const u8, target: []const u8, - state: journal_mod.State, + state: Journal.State, ) !Journal { const obs_dup = try allocator.dupe(u8, observation); const tgt_dup = try allocator.dupe(u8, target); const empty_notes = try allocator.alloc([]const u8, 0); - const entries = try allocator.alloc(JournalEntry, 1); + const entries = try allocator.alloc(Journal.Entry, 1); entries[0] = .{ .ack = .{ .observation = obs_dup, @@ -236,7 +234,7 @@ fn makeJournalWithAck( } fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal { - const entries = try allocator.alloc(JournalEntry, 0); + const entries = try allocator.alloc(Journal.Entry, 0); return .{ .allocator = allocator, .entries = entries }; } diff --git a/src/views/review.zig b/src/views/review.zig index db8d3cb..067c80d 100644 --- a/src/views/review.zig +++ b/src/views/review.zig @@ -33,6 +33,7 @@ const analysis = @import("../analytics/analysis.zig"); const performance = @import("../analytics/performance.zig"); const risk = @import("../analytics/risk.zig"); const portfolio_risk = @import("../analytics/portfolio_risk.zig"); +const observations_engine = @import("../analytics/observations.zig"); const classification = @import("../models/classification.zig"); const valuation = @import("../analytics/valuation.zig"); const format = @import("../format.zig"); @@ -150,9 +151,15 @@ pub const ReviewView = struct { total_liquid: f64, /// Anchor portfolio file path for the header line. Borrowed. portfolio_path: []const u8, + /// Observation engine output. Null when the engine wasn't run + /// (e.g. unit tests of the renderer that fabricate views without + /// going through `buildReview`). Populated by `buildReview` after + /// computing rows + totals. + observations: ?observations_engine.CheckPanel = null, pub fn deinit(self: *ReviewView, allocator: std.mem.Allocator) void { allocator.free(self.rows); + if (self.observations) |*panel| panel.deinit(); } }; @@ -175,6 +182,7 @@ pub const ReviewView = struct { /// `as_of` is the reference date for trailing-window math. pub fn buildReview( allocator: std.mem.Allocator, + io: std.Io, summary: valuation.PortfolioSummary, candle_map: *const std.StringHashMap([]const zfin.Candle), dividend_map: ?*const std.StringHashMap([]const zfin.Dividend), @@ -233,12 +241,26 @@ pub fn buildReview( const totals = computeTotals(rows.items, synth); + const rows_slice = try rows.toOwnedSlice(allocator); + errdefer allocator.free(rows_slice); + + // Run the observations engine over the now-built rows + totals. + // Sync today; the API takes `io` so a future async dispatch path + // doesn't require a signature change. + const obs_ctx: observations_engine.CheckCtx = .{ + .allocator = allocator, + .rows = rows_slice, + .totals = totals, + }; + const panel = try observations_engine.runChecks(allocator, io, obs_ctx, &observations_engine.default_checks); + return .{ - .rows = try rows.toOwnedSlice(allocator), + .rows = rows_slice, .totals = totals, .as_of = as_of, .total_liquid = summary.total_value, .portfolio_path = portfolio_path, + .observations = panel, }; } @@ -1145,6 +1167,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" { var view = try buildReview( testing.allocator, + std.testing.io, summary, &candle_map, null, // no dividend map