clear up the attributions output

This commit is contained in:
Emil Lerch 2026-05-02 11:10:39 -07:00
parent 67351bc936
commit f28d98f708
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -499,35 +499,46 @@ fn renderTotalsLine(out: *std.Io.Writer, color: bool, t: view.TotalsRow) !void {
try cli.printIntent(out, color, c.style, " {s} {s}\n", .{ c.delta, c.pct });
}
/// Render the contributions-vs-gains attribution line directly beneath
/// the Liquid totals. Matches the email format:
/// Render the investment-gains vs. cash-contributions breakdown of
/// the liquid delta, stacked as two labeled rows directly beneath
/// the Liquid line:
///
/// Attribution: +$30,148.02 delta = +$22,636.00 contributions + +$7,512.02 gains
/// Investment gains: +$85,062.72
/// Cash contributions: +$7,866.22
///
/// All three amounts are signed: negative contributions (net
/// withdrawal) and negative gains (market loss) both print with a
/// leading `-`. Indent of the label column aligns with "Liquid:".
/// Both amounts are signed. Negative gains (market loss) and
/// negative contributions (net withdrawal) print with a leading `-`
/// in the negative color. Labels use the muted color to stay out of
/// the way of the eye scan for the numbers.
///
/// Labels are padded to a fixed width so the `$` column aligns
/// regardless of label length. Indent of 2 spaces matches the
/// gainer/loser summary beneath the per-symbol table.
///
/// Math identity: `delta = gains + contributions`, so
/// `gains = delta - contributions`. The caller derives `gains` from
/// the liquid delta before this is called; we just render.
fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attribution: view.Attribution) !void {
var delta_buf: [32]u8 = undefined;
var contrib_buf: [32]u8 = undefined;
_ = delta; // delta is already shown on the Liquid row above; we
// don't repeat it here. Kept in the signature so the
// callsite stays readable (`renderAttributionLine(out,
// color, cv.liquid.delta, a)`) and future revisions
// that want to restate the Δ have it in scope.
var gains_buf: [32]u8 = undefined;
const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta);
const contrib_str = view_hist.fmtSignedMoneyBuf(&contrib_buf, attribution.contributions);
var contrib_buf: [32]u8 = undefined;
const gains_str = view_hist.fmtSignedMoneyBuf(&gains_buf, attribution.gains);
const contrib_str = view_hist.fmtSignedMoneyBuf(&contrib_buf, attribution.contributions);
// Sign-aware joiner between `contributions` and `gains`:
// gains >= 0 " + " (explicit addition).
// gains < 0 " " (the leading "-" on gains_str carries the sign;
// avoids visual clutter of "+$X + -$Y").
const joiner: []const u8 = if (attribution.gains >= 0) " + " else " ";
try cli.printFg(out, color, cli.CLR_MUTED, "Attribution: ", .{});
try cli.printGainLoss(out, color, delta, "{s}", .{delta_str});
try cli.printFg(out, color, cli.CLR_MUTED, " delta = ", .{});
try cli.printGainLoss(out, color, attribution.contributions, "{s}", .{contrib_str});
try cli.printFg(out, color, cli.CLR_MUTED, " contributions{s}", .{joiner});
try cli.printGainLoss(out, color, attribution.gains, "{s}", .{gains_str});
try cli.printFg(out, color, cli.CLR_MUTED, " gains\n", .{});
// 19-char label column aligns the amount columns. "Investment
// gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is
// 19 chars 0 trailing pad. The 2-space gutter that follows
// keeps the amounts clearly separated from the labels even on
// narrow terminals.
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
try cli.printGainLoss(out, color, attribution.gains, "{s}\n", .{gains_str});
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Cash contributions:"});
try cli.printGainLoss(out, color, attribution.contributions, "{s}\n", .{contrib_str});
}
fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void {
@ -738,12 +749,13 @@ test "renderCompare: attribution line when attribution is set" {
try renderCompare(&stream, false, cv);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Attribution:") != null);
try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null);
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") != null);
try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") != null);
try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null); // on the Liquid row above
try testing.expect(std.mem.indexOf(u8, out, "+$22,636.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null);
try testing.expect(std.mem.indexOf(u8, out, "contributions") != null);
try testing.expect(std.mem.indexOf(u8, out, "gains") != null);
// The old `Attribution:` prefix is gone.
try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null);
}
test "renderCompare: no attribution line when attribution is null" {
@ -765,7 +777,8 @@ test "renderCompare: no attribution line when attribution is null" {
try renderCompare(&stream, false, cv);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null);
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") == null);
try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") == null);
}
test "renderCompare: attribution handles negative gains" {