include observations in review tab
This commit is contained in:
parent
4ed3b91fce
commit
474d288c4c
5 changed files with 1845 additions and 179 deletions
|
|
@ -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
|
||||
/// "<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 {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<path>.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)));
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue