include observations in review tab

This commit is contained in:
Emil Lerch 2026-06-09 13:27:16 -07:00
parent 4ed3b91fce
commit 474d288c4c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 1845 additions and 179 deletions

View file

@ -13,11 +13,28 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const review_view = @import("../views/review.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"); const portfolio_risk = @import("../analytics/portfolio_risk.zig");
pub const ParsedArgs = struct { pub const ParsedArgs = struct {
sort: ?review_view.SortField = null, sort: ?review_view.SortField = null,
sort_dir: review_view.SortDirection = .desc, 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 = .{ pub const meta: framework.Meta = .{
@ -44,6 +61,11 @@ pub const meta: framework.Meta = .{
\\ 5y-maxdd \\ 5y-maxdd
\\ --asc Sort ascending (default: descending for \\ --asc Sort ascending (default: descending for
\\ numeric fields, ascending for symbol/sector) \\ 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 \\Reads classifications from `metadata.srf` and account tax types
\\from `accounts.srf`. Tax% is the share of each holding's market \\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, .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 { 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; parsed.sort_dir = .asc;
} else if (std.mem.eql(u8, arg, "--desc")) { } else if (std.mem.eql(u8, arg, "--desc")) {
parsed.sort_dir = .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 { } else {
cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n"); cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n");
return error.UnexpectedArg; return error.UnexpectedArg;
@ -81,6 +111,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
return parsed; 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. /// Comma-joined valid sort fields for the user-facing error message.
/// Built once at comptime so we don't allocate at error time. /// Built once at comptime so we don't allocate at error time.
const joined_sort_fields: []const u8 = blk: { 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( var view = try review_view.buildReview(
allocator, allocator,
io,
pf_data.summary, pf_data.summary,
&pf_data.candle_map, &pf_data.candle_map,
&dividend_map, &dividend_map,
@ -191,7 +229,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
review_view.sortGroupedByDefault(view.rows); review_view.sortGroupedByDefault(view.rows);
} }
try render(out, color, view); try render(allocator, io, out, color, view, anchor_path, parsed);
} }
// Rendering // Rendering
@ -227,7 +265,15 @@ const col_widths = [_]usize{
col_tax, 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.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path});
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" As of {f} Liquid: {f} Holdings: {d}\n\n", .{ 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); 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`. // Header row. Column order matches `col_widths`.
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{}); 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); 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", .{}); try out.print("\n", .{});
} }
/// Render the per-check status grid to stdout. Layout mirrors
/// the TUI's `appendStatusGrid`: 3 cells per row, each cell
/// "<right-padded label> <glyph>". 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 { fn writeSeparator(out: *std.Io.Writer, color: bool) !void {
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{}); try out.print(" ", .{});
@ -475,6 +684,44 @@ test "parseArgs: positional arg errors" {
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); 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" { test "joinSortFields: contains all field names" {
const joined = joinSortFields(); const joined = joinSortFields();
for (review_view.sort_field_names) |name| { 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, .total_liquid = 1_000_000.0,
.portfolio_path = "test_portfolio.srf", .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(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null); try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null);
try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != 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, .total_liquid = 0,
.portfolio_path = "x.srf", .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(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null); try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
} }

View file

@ -55,11 +55,10 @@
//! ## Lifecycle //! ## Lifecycle
//! //!
//! - **Read:** single-pass iterator over the file. Acks push a new //! - **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`. //! `error.OrphanedNote`.
//! - **Write:** `append` / `setState` mutate the in-memory `entries` and //! - **Write:** `append` / `setState` mutate the in-memory `entries` and
//! atomic-rewrite the file (temp file + rename). Always rewriting keeps //! atomic-rewrite the file via `atomic.writeFileAtomic`.
//! the file clean; git tracks history.
//! - **Concurrency:** the file is per-portfolio (sibling of //! - **Concurrency:** the file is per-portfolio (sibling of
//! `portfolio.srf`); concurrent zfin invocations on the same portfolio //! `portfolio.srf`); concurrent zfin invocations on the same portfolio
//! would race, but that's the existing convention for every sibling //! 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 Date = @import("../Date.zig");
const atomic = @import("../atomic.zig"); const atomic = @import("../atomic.zig");
const Journal = @This();
allocator: std.mem.Allocator,
entries: []Entry,
pub const State = enum { pub const State = enum {
active, active,
acknowledged, acknowledged,
@ -89,7 +93,7 @@ pub const Acknowledgment = struct {
/// Wire-format note record: just a single line of text. Used only by /// 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 /// 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 { const NoteRecord = struct {
line: []const u8, line: []const u8,
}; };
@ -102,10 +106,9 @@ const JournalRecord = union(enum) {
note: NoteRecord, note: NoteRecord,
}; };
/// In-memory ack with its notes already grouped. Built by /// In-memory ack with its notes already grouped. Built by `load`;
/// `Journal.load`; consumed by callers that want "the ack and its /// consumed by callers that want "the ack and its reasoning together."
/// reasoning together." pub const Entry = struct {
pub const JournalEntry = struct {
ack: Acknowledgment, ack: Acknowledgment,
/// Note fragments in the order the user entered them. Each is an /// Note fragments in the order the user entered them. Each is an
/// allocator-owned slice; freed by `Journal.deinit`. /// allocator-owned slice; freed by `Journal.deinit`.
@ -113,46 +116,40 @@ pub const JournalEntry = struct {
/// Concatenate the notes with newlines into a single string. /// Concatenate the notes with newlines into a single string.
/// Allocator-owned; caller frees. /// 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); return try std.mem.join(allocator, "\n", self.notes);
} }
}; };
/// In-memory journal state. Owns all string data and the entries slice. pub fn deinit(self: *Journal) void {
pub const Journal = struct { const a = self.allocator;
allocator: std.mem.Allocator, for (self.entries) |entry| {
entries: []JournalEntry, a.free(entry.ack.observation);
a.free(entry.ack.target);
pub fn deinit(self: *Journal) void { for (entry.notes) |line| a.free(line);
const a = self.allocator; a.free(entry.notes);
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;
} }
a.free(self.entries);
self.* = undefined;
}
/// Find the entry matching `(observation, target)`. Returns null /// Find the entry matching `(observation, target)`. Returns null if
/// if not found. There's only ever one entry per pair (we don't /// not found. There's only ever one entry per pair (we don't
/// preserve transition history), so no tiebreaker is needed. /// preserve transition history), so no tiebreaker is needed.
pub fn findByTarget( pub fn findByTarget(
self: *const Journal, self: *const Journal,
observation: []const u8, observation: []const u8,
target: []const u8, target: []const u8,
) ?*const JournalEntry { ) ?*const Entry {
for (self.entries) |*e| { for (self.entries) |*e| {
if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
if (!std.mem.eql(u8, e.ack.target, target)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue;
return e; return e;
}
return null;
} }
}; 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 /// file doesn't exist (first-time use case). `path` should be an
/// absolute or cwd-relative path to `acknowledgments.srf`. /// absolute or cwd-relative path to `acknowledgments.srf`.
pub fn load( pub fn load(
@ -162,7 +159,7 @@ pub fn load(
) !Journal { ) !Journal {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) { const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| switch (err) {
error.FileNotFound => { error.FileNotFound => {
return .{ .allocator = allocator, .entries = try allocator.alloc(JournalEntry, 0) }; return .{ .allocator = allocator, .entries = try allocator.alloc(Entry, 0) };
}, },
else => return err, else => return err,
}; };
@ -191,17 +188,17 @@ pub fn parse(allocator: std.mem.Allocator, data: []const u8) !Journal {
if (data.len == 0) { if (data.len == 0) {
return .{ return .{
.allocator = allocator, .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); errdefer freeEntries(allocator, &entries);
// Per-entry notes lists. Lives parallel to `entries` and is // Per-entry notes lists. Lives parallel to `entries` and is
// converted to owned slices at the end. We use a separate list // converted to owned slices at the end. We use a separate list
// (instead of mutating each entry's `notes` field as we go) // (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. // slice so we can't append to it after the entry is created.
var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty; var notes_per_entry = std.ArrayList(std.ArrayList([]const u8)).empty;
errdefer { 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). /// 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| { for (entries.items) |entry| {
allocator.free(entry.ack.observation); allocator.free(entry.ack.observation);
allocator.free(entry.ack.target); 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). /// entered (one Enter press per fragment in the TUI's note input).
/// Pass an empty slice to record an ack with no reasoning. /// Pass an empty slice to record an ack with no reasoning.
pub fn append( pub fn append(
journal: *Journal, self: *Journal,
io: std.Io, io: std.Io,
path: []const u8, path: []const u8,
new_ack: Acknowledgment, new_ack: Acknowledgment,
note_fragments: []const []const u8, note_fragments: []const []const u8,
) !void { ) !void {
const a = journal.allocator; const a = self.allocator;
// Build the new entry's owned strings first. errdefer cleanup // Build the new entry's owned strings first. errdefer cleanup
// is fiddly because we need to roll back partial allocations // 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. // 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); errdefer a.free(new_entries);
@memcpy(new_entries[0..journal.entries.len], journal.entries); @memcpy(new_entries[0..self.entries.len], self.entries);
new_entries[journal.entries.len] = .{ new_entries[self.entries.len] = .{
.ack = .{ .ack = .{
.observation = owned_obs, .observation = owned_obs,
.target = owned_target, .target = owned_target,
@ -317,10 +314,10 @@ pub fn append(
// Replace the slice WITHOUT freeing the old strings they're // Replace the slice WITHOUT freeing the old strings they're
// shallow-copied into new_entries above. Just free the old slice. // shallow-copied into new_entries above. Just free the old slice.
a.free(journal.entries); a.free(self.entries);
journal.entries = new_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 /// 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, /// Returns `error.AckNotFound` if no entry matches `(observation,
/// target)`. /// target)`.
pub fn setState( pub fn setState(
journal: *Journal, self: *Journal,
io: std.Io, io: std.Io,
path: []const u8, path: []const u8,
observation: []const u8, observation: []const u8,
@ -343,8 +340,8 @@ pub fn setState(
new_state: State, new_state: State,
today: Date, today: Date,
) !void { ) !void {
var found: ?*JournalEntry = null; var found: ?*Entry = null;
for (journal.entries) |*e| { for (self.entries) |*e| {
if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
if (!std.mem.eql(u8, e.ack.target, target)) continue; if (!std.mem.eql(u8, e.ack.target, target)) continue;
found = e; found = e;
@ -366,14 +363,14 @@ pub fn setState(
} }
entry.ack.state = new_state; 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 file write: serialize all records, then hand to
/// `atomic.writeFileAtomic` which writes to `<path>.tmp`, fsyncs, /// `atomic.writeFileAtomic` which writes to `<path>.tmp`, fsyncs,
/// and renames. Crash-safe. /// and renames. Crash-safe.
fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void { fn writeFile(self: *const Journal, io: std.Io, path: []const u8) !void {
const a = journal.allocator; const a = self.allocator;
// Build the file contents in memory. For a journal of typical // Build the file contents in memory. For a journal of typical
// size (dozens to low hundreds of records) this is trivially // 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 // `#!srfv1` directive header once at the top and emits every
// record through its native formatter. Ack records are followed // record through its native formatter. Ack records are followed
// immediately by their note records, in entry-list order. // immediately by their note records, in entry-list order.
var total_records: usize = journal.entries.len; var total_records: usize = self.entries.len;
for (journal.entries) |e| total_records += e.notes.len; for (self.entries) |e| total_records += e.notes.len;
var records = try a.alloc(JournalRecord, total_records); var records = try a.alloc(JournalRecord, total_records);
defer a.free(records); defer a.free(records);
var ri: usize = 0; var ri: usize = 0;
for (journal.entries) |e| { for (self.entries) |e| {
records[ri] = .{ .acknowledgment = e.ack }; records[ri] = .{ .acknowledgment = e.ack };
ri += 1; ri += 1;
for (e.notes) |line| { for (e.notes) |line| {
@ -409,9 +406,9 @@ fn writeFile(journal: *const Journal, io: std.Io, path: []const u8) !void {
const testing = std.testing; const testing = std.testing;
test "parse: empty input produces empty journal" { test "parse: empty input produces empty journal" {
var j = try parse(testing.allocator, ""); var journal = try parse(testing.allocator, "");
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len); try testing.expectEqual(@as(usize, 0), journal.entries.len);
} }
test "parse: single ack with two notes round-trips" { 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::Holding through earnings cycle.
\\type::note,line::Will trim by Q3 2026. \\type::note,line::Will trim by Q3 2026.
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(@as(usize, 1), j.entries.len); try testing.expectEqual(@as(usize, 1), journal.entries.len);
const entry = j.entries[0]; const entry = journal.entries[0];
try testing.expectEqualStrings("position_concentration", entry.ack.observation); try testing.expectEqualStrings("position_concentration", entry.ack.observation);
try testing.expectEqualStrings("NVDA", entry.ack.target); try testing.expectEqualStrings("NVDA", entry.ack.target);
try testing.expectEqual(State.acknowledged, entry.ack.state); 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::for B
\\type::note,line::also for B \\type::note,line::also for B
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(@as(usize, 2), j.entries.len); try testing.expectEqual(@as(usize, 2), journal.entries.len);
try testing.expectEqual(@as(usize, 1), j.entries[0].notes.len); try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
try testing.expectEqualStrings("for A", j.entries[0].notes[0]); try testing.expectEqualStrings("for A", journal.entries[0].notes[0]);
try testing.expectEqual(@as(usize, 2), j.entries[1].notes.len); try testing.expectEqual(@as(usize, 2), journal.entries[1].notes.len);
try testing.expectEqualStrings("for B", j.entries[1].notes[0]); try testing.expectEqualStrings("for B", journal.entries[1].notes[0]);
try testing.expectEqualStrings("also for B", j.entries[1].notes[1]); try testing.expectEqualStrings("also for B", journal.entries[1].notes[1]);
} }
test "parse: orphan note before any ack returns error.OrphanedNote" { 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::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 \\type::acknowledgment,observation::sector_concentration,target::sector:Technology,acknowledged_at::2026-06-13,state::acknowledged
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
const found = j.findByTarget("position_concentration", "NVDA").?; const found = journal.findByTarget("position_concentration", "NVDA").?;
try testing.expectEqualStrings("NVDA", found.ack.target); 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); try testing.expect(not_found == null);
} }
test "JournalEntry.fullNote: joins fragments with newlines" { test "Entry.fullNote: joins fragments with newlines" {
const data = const data =
\\#!srfv1 \\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active \\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::second
\\type::note,line::third \\type::note,line::third
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
const full = try j.entries[0].fullNote(testing.allocator); const full = try journal.entries[0].fullNote(testing.allocator);
defer testing.allocator.free(full); defer testing.allocator.free(full);
try testing.expectEqualStrings("first\nsecond\nthird", 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 = const data =
\\#!srfv1 \\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
const full = try j.entries[0].fullNote(testing.allocator); const full = try journal.entries[0].fullNote(testing.allocator);
defer testing.allocator.free(full); defer testing.allocator.free(full);
try testing.expectEqualStrings("", full); try testing.expectEqualStrings("", full);
} }
@ -509,10 +506,10 @@ test "parse: optional unacknowledged_at and resolved_at fields work when omitted
\\#!srfv1 \\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged \\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::acknowledged
; ;
var j = try parse(testing.allocator, data); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
try testing.expect(j.entries[0].ack.unacknowledged_at == null); try testing.expect(journal.entries[0].ack.unacknowledged_at == null);
try testing.expect(j.entries[0].ack.resolved_at == null); try testing.expect(journal.entries[0].ack.resolved_at == null);
} }
test "parse: optional unacknowledged_at and resolved_at fields work when set" { 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 \\#!srfv1
\\type::acknowledgment,observation::p,target::T,acknowledged_at::2026-06-12,state::active,unacknowledged_at::2026-08-01,resolved_at::2026-12-01 \\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); var journal = try parse(testing.allocator, data);
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(Date.fromYmd(2026, 8, 1).days, j.entries[0].ack.unacknowledged_at.?.days); 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, j.entries[0].ack.resolved_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" { 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}); const path = try std.fmt.allocPrint(allocator, "{s}/does_not_exist.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len); try testing.expectEqual(@as(usize, 0), journal.entries.len);
} }
test "append + load round-trip: ack with two notes" { 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}); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try testing.expectEqual(@as(usize, 0), j.entries.len); try testing.expectEqual(@as(usize, 0), journal.entries.len);
const fragments = [_][]const u8{ "first thought", "follow-up rationale" }; const fragments = [_][]const u8{ "first thought", "follow-up rationale" };
try append(&j, io, path, .{ try journal.append(io, path, .{
.observation = "position_concentration", .observation = "position_concentration",
.target = "NVDA", .target = "NVDA",
.acknowledged_at = Date.fromYmd(2026, 6, 8), .acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged, .state = .acknowledged,
}, &fragments); }, &fragments);
var j2 = try load(allocator, io, path); var reloaded = try load(allocator, io, path);
defer j2.deinit(); defer reloaded.deinit();
try testing.expectEqual(@as(usize, 1), j2.entries.len); try testing.expectEqual(@as(usize, 1), reloaded.entries.len);
try testing.expectEqualStrings("position_concentration", j2.entries[0].ack.observation); try testing.expectEqualStrings("position_concentration", reloaded.entries[0].ack.observation);
try testing.expectEqualStrings("NVDA", j2.entries[0].ack.target); try testing.expectEqualStrings("NVDA", reloaded.entries[0].ack.target);
try testing.expectEqual(State.acknowledged, j2.entries[0].ack.state); try testing.expectEqual(State.acknowledged, reloaded.entries[0].ack.state);
try testing.expectEqual(@as(usize, 2), j2.entries[0].notes.len); try testing.expectEqual(@as(usize, 2), reloaded.entries[0].notes.len);
try testing.expectEqualStrings("first thought", j2.entries[0].notes[0]); try testing.expectEqualStrings("first thought", reloaded.entries[0].notes[0]);
try testing.expectEqualStrings("follow-up rationale", j2.entries[0].notes[1]); try testing.expectEqualStrings("follow-up rationale", reloaded.entries[0].notes[1]);
} }
test "append: two acks land in append-order on reload" { 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}); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try append(&j, io, path, .{ try journal.append(io, path, .{
.observation = "k", .observation = "k",
.target = "B", .target = "B",
.acknowledged_at = Date.fromYmd(2026, 6, 8), .acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged, .state = .acknowledged,
}, &.{}); }, &.{});
try append(&j, io, path, .{ try journal.append(io, path, .{
.observation = "k", .observation = "k",
.target = "A", .target = "A",
.acknowledged_at = Date.fromYmd(2026, 6, 8), .acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged, .state = .acknowledged,
}, &.{}); }, &.{});
var j2 = try load(allocator, io, path); var reloaded = try load(allocator, io, path);
defer j2.deinit(); defer reloaded.deinit();
try testing.expectEqual(@as(usize, 2), j2.entries.len); try testing.expectEqual(@as(usize, 2), reloaded.entries.len);
try testing.expectEqualStrings("B", j2.entries[0].ack.target); try testing.expectEqualStrings("B", reloaded.entries[0].ack.target);
try testing.expectEqualStrings("A", j2.entries[1].ack.target); try testing.expectEqualStrings("A", reloaded.entries[1].ack.target);
} }
test "setState: acknowledged → active sets unacknowledged_at" { 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}); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try append(&j, io, path, .{ try journal.append(io, path, .{
.observation = "k", .observation = "k",
.target = "X", .target = "X",
.acknowledged_at = Date.fromYmd(2026, 6, 8), .acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged, .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); var reloaded = try load(allocator, io, path);
defer j2.deinit(); defer reloaded.deinit();
try testing.expectEqual(State.active, j2.entries[0].ack.state); try testing.expectEqual(State.active, reloaded.entries[0].ack.state);
try testing.expect(j2.entries[0].ack.unacknowledged_at != null); try testing.expect(reloaded.entries[0].ack.unacknowledged_at != null);
try testing.expect(j2.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9))); try testing.expect(reloaded.entries[0].ack.unacknowledged_at.?.eql(Date.fromYmd(2026, 6, 9)));
} }
test "setState: missing target returns AckNotFound" { 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}); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try testing.expectError( try testing.expectError(
error.AckNotFound, 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}); const path = try std.fmt.allocPrint(allocator, "{s}/journal.srf", .{dir_path});
defer allocator.free(path); defer allocator.free(path);
var j = try load(allocator, io, path); var journal = try load(allocator, io, path);
defer j.deinit(); defer journal.deinit();
try append(&j, io, path, .{ try journal.append(io, path, .{
.observation = "k", .observation = "k",
.target = "X", .target = "X",
.acknowledged_at = Date.fromYmd(2026, 6, 8), .acknowledged_at = Date.fromYmd(2026, 6, 8),
.state = .acknowledged, .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); var reloaded = try load(allocator, io, path);
defer j2.deinit(); defer reloaded.deinit();
try testing.expectEqual(State.resolved, j2.entries[0].ack.state); try testing.expectEqual(State.resolved, reloaded.entries[0].ack.state);
try testing.expect(j2.entries[0].ack.resolved_at != null); try testing.expect(reloaded.entries[0].ack.resolved_at != null);
try testing.expect(j2.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10))); try testing.expect(reloaded.entries[0].ack.resolved_at.?.eql(Date.fromYmd(2026, 6, 10)));
} }

File diff suppressed because it is too large Load diff

View file

@ -8,11 +8,11 @@
//! - The **engine** (`analytics/observations.zig`) produces a //! - The **engine** (`analytics/observations.zig`) produces a
//! `CheckPanel` whose `pending` slice has one entry per registered //! `CheckPanel` whose `pending` slice has one entry per registered
//! check, each with a `CheckResult` of `pass`/`warn`/`flag`/`skipped`/`err`. //! 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)`. //! acknowledgments durable records keyed by `(observation, target)`.
//! - This **view** matches the two: every `Observation` in a //! - This **view** matches the two: every `Observation` in a
//! `warn`/`flag` result becomes a `FindingRow`. The row is marked //! `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)`. //! and matching `(observation, target)`.
//! //!
//! ## Lifetime //! ## Lifetime
@ -32,13 +32,11 @@
const std = @import("std"); const std = @import("std");
const observations = @import("../analytics/observations.zig"); const observations = @import("../analytics/observations.zig");
const journal_mod = @import("../data/journal.zig"); const Journal = @import("../data/Journal.zig");
const Observation = observations.Observation; const Observation = observations.Observation;
const Severity = observations.Severity; const Severity = observations.Severity;
const CheckPanel = observations.CheckPanel; const CheckPanel = observations.CheckPanel;
const Journal = journal_mod.Journal;
const JournalEntry = journal_mod.JournalEntry;
/// One displayable finding. Borrows all string data from the /// One displayable finding. Borrows all string data from the
/// underlying `Observation` and (optionally) the matching journal /// underlying `Observation` and (optionally) the matching journal
@ -57,7 +55,7 @@ pub const FindingRow = struct {
/// The matching journal entry (when `is_acked == true`), so /// The matching journal entry (when `is_acked == true`), so
/// renderers can pull notes/acknowledged_at out without re-doing /// renderers can pull notes/acknowledged_at out without re-doing
/// the lookup. Null when no ack matches. /// 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 /// 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). /// Linear scan is fine for realistic journal sizes (tens of entries).
/// If a portfolio ever accumulates hundreds of acks we can index by /// If a portfolio ever accumulates hundreds of acks we can index by
/// (observation, target) at load time. /// (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| { for (journal.entries) |*e| {
if (e.ack.state != .acknowledged) continue; if (e.ack.state != .acknowledged) continue;
if (!std.mem.eql(u8, e.ack.observation, observation)) continue; if (!std.mem.eql(u8, e.ack.observation, observation)) continue;
@ -217,12 +215,12 @@ fn makeJournalWithAck(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
observation: []const u8, observation: []const u8,
target: []const u8, target: []const u8,
state: journal_mod.State, state: Journal.State,
) !Journal { ) !Journal {
const obs_dup = try allocator.dupe(u8, observation); const obs_dup = try allocator.dupe(u8, observation);
const tgt_dup = try allocator.dupe(u8, target); const tgt_dup = try allocator.dupe(u8, target);
const empty_notes = try allocator.alloc([]const u8, 0); 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] = .{ entries[0] = .{
.ack = .{ .ack = .{
.observation = obs_dup, .observation = obs_dup,
@ -236,7 +234,7 @@ fn makeJournalWithAck(
} }
fn makeEmptyJournal(allocator: std.mem.Allocator) !Journal { 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 }; return .{ .allocator = allocator, .entries = entries };
} }

View file

@ -33,6 +33,7 @@ const analysis = @import("../analytics/analysis.zig");
const performance = @import("../analytics/performance.zig"); const performance = @import("../analytics/performance.zig");
const risk = @import("../analytics/risk.zig"); const risk = @import("../analytics/risk.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig"); const portfolio_risk = @import("../analytics/portfolio_risk.zig");
const observations_engine = @import("../analytics/observations.zig");
const classification = @import("../models/classification.zig"); const classification = @import("../models/classification.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const format = @import("../format.zig"); const format = @import("../format.zig");
@ -150,9 +151,15 @@ pub const ReviewView = struct {
total_liquid: f64, total_liquid: f64,
/// Anchor portfolio file path for the header line. Borrowed. /// Anchor portfolio file path for the header line. Borrowed.
portfolio_path: []const u8, 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 { pub fn deinit(self: *ReviewView, allocator: std.mem.Allocator) void {
allocator.free(self.rows); 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. /// `as_of` is the reference date for trailing-window math.
pub fn buildReview( pub fn buildReview(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
io: std.Io,
summary: valuation.PortfolioSummary, summary: valuation.PortfolioSummary,
candle_map: *const std.StringHashMap([]const zfin.Candle), candle_map: *const std.StringHashMap([]const zfin.Candle),
dividend_map: ?*const std.StringHashMap([]const zfin.Dividend), dividend_map: ?*const std.StringHashMap([]const zfin.Dividend),
@ -233,12 +241,26 @@ pub fn buildReview(
const totals = computeTotals(rows.items, synth); 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 .{ return .{
.rows = try rows.toOwnedSlice(allocator), .rows = rows_slice,
.totals = totals, .totals = totals,
.as_of = as_of, .as_of = as_of,
.total_liquid = summary.total_value, .total_liquid = summary.total_value,
.portfolio_path = portfolio_path, .portfolio_path = portfolio_path,
.observations = panel,
}; };
} }
@ -1145,6 +1167,7 @@ test "buildReview: end-to-end with testing allocator (leak check)" {
var view = try buildReview( var view = try buildReview(
testing.allocator, testing.allocator,
std.testing.io,
summary, summary,
&candle_map, &candle_map,
null, // no dividend map null, // no dividend map