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 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,
|
||||||
÷nd_map,
|
÷nd_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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue