460 lines
18 KiB
Zig
460 lines
18 KiB
Zig
//! `src/views/history.zig` — view models for the portfolio history UX.
|
|
//!
|
|
//! Sits alongside `views/portfolio_sections.zig` in the views layer,
|
|
//! which owns renderer-agnostic display data consumed by both CLI and
|
|
//! TUI. Column widths, format strings, computed values, and style
|
|
//! decisions are defined here; renderers are thin adapters that map
|
|
//! `StyleIntent` to platform-specific styles and emit pre-formatted
|
|
//! text.
|
|
//!
|
|
//! Contract: no IO, no writer, no ANSI, no `vaxis.Cell.Style`. Functions
|
|
//! consume `timeline` types and format primitives from `src/format.zig`,
|
|
//! and return formatted strings or structured data.
|
|
//!
|
|
//! Why this layer exists: the CLI and TUI render the same sections
|
|
//! (windows block, recent-snapshots table) from the same data. Before
|
|
//! this module, both reimplemented the formatting in parallel and
|
|
//! drifted (column widths didn't match, labels differed, row styling
|
|
//! diverged). Centralizing the row-cell layout here guarantees parity.
|
|
|
|
const std = @import("std");
|
|
const timeline = @import("../analytics/timeline.zig");
|
|
const fmt = @import("../format.zig");
|
|
const StyleIntent = fmt.StyleIntent;
|
|
|
|
// ── Column widths (shared by CLI + TUI) ──────────────────────
|
|
|
|
/// Width of the leftmost label column in the windows block.
|
|
/// Fits "10 years" + padding. `{s:<12}` (left-aligned).
|
|
pub const windows_label_width: usize = 12;
|
|
|
|
/// Width of the Δ (signed money) column. Fits worst-case like
|
|
/// `"+$99,999,999.99"` (15 chars) with slack. Right-aligned.
|
|
pub const windows_delta_width: usize = 18;
|
|
|
|
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
|
|
pub const windows_pct_width: usize = 10;
|
|
|
|
/// Width of the annualized % (CAGR) column. Same shape as
|
|
/// `windows_pct_width`. Right-aligned.
|
|
pub const windows_ann_width: usize = 10;
|
|
|
|
/// Width of the date column in the recent-snapshots table.
|
|
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
|
|
pub const table_date_width: usize = 10;
|
|
|
|
/// Width of each `"$value (±Δ)"` composite cell in the table.
|
|
/// Composed of two sub-columns:
|
|
/// - value: right-aligned dollar amount (`value_subcol_width`)
|
|
/// - delta: left-aligned signed delta (`delta_subcol_width`)
|
|
/// separated by a single space. Both sub-cells are padded
|
|
/// independently so leading-digit columns line up vertically
|
|
/// across rows of varying magnitude.
|
|
///
|
|
/// Worst case sizes:
|
|
/// value: `$99,999,999.99` = 14 chars
|
|
/// delta: `(+$9,999,999.99)` = 16 chars
|
|
/// Composite total = 14 + 1 + 16 = 31 chars.
|
|
pub const value_subcol_width: usize = 14;
|
|
pub const delta_subcol_width: usize = 16;
|
|
pub const table_cell_width: usize = value_subcol_width + 1 + delta_subcol_width;
|
|
|
|
// ── Windows block row ────────────────────────────────────────
|
|
|
|
/// One row's worth of pre-formatted cells for the windows block.
|
|
/// Strings reference caller-owned buffers passed into
|
|
/// `buildWindowRowCells`; do not outlive them.
|
|
///
|
|
/// `style` is a semantic intent — the renderer maps it to the
|
|
/// platform's actual color. Zero-delta and missing-anchor rows both
|
|
/// resolve to `.muted` because neither deserves a green/red shout in
|
|
/// practice; no caller today needs to distinguish them.
|
|
pub const WindowRowCells = struct {
|
|
label: []const u8,
|
|
delta_str: []const u8,
|
|
pct_str: []const u8,
|
|
/// Annualized (CAGR) percentage, formatted with sign and `%`.
|
|
/// Same `"n/a"` fallback as `pct_str` when input is null —
|
|
/// missing anchor or non-finite math.
|
|
ann_str: []const u8,
|
|
style: StyleIntent,
|
|
};
|
|
|
|
/// Render a WindowStat into displayable cells. `delta_buf`,
|
|
/// `pct_buf`, and `ann_buf` are caller-owned stack buffers the
|
|
/// returned strings borrow from — they must outlive the returned
|
|
/// struct.
|
|
///
|
|
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
|
|
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
|
|
/// style. Positive/negative deltas map to `.positive` / `.negative`.
|
|
///
|
|
/// The pct columns never show a trailing-whitespace oddity: sign and
|
|
/// digits sit flush (`"+0.41%"`, not `"+ 0.41%"`). Column alignment
|
|
/// is the caller's job via `windows_pct_width` / `windows_ann_width`.
|
|
pub fn buildWindowRowCells(
|
|
row: timeline.WindowStat,
|
|
delta_buf: *[32]u8,
|
|
pct_buf: *[16]u8,
|
|
ann_buf: *[16]u8,
|
|
) WindowRowCells {
|
|
const style: StyleIntent = blk: {
|
|
const d = row.delta_abs orelse break :blk .muted;
|
|
if (d > 0) break :blk .positive;
|
|
if (d < 0) break :blk .negative;
|
|
break :blk .muted; // zero delta → muted (same as missing)
|
|
};
|
|
|
|
const delta_str: []const u8 = if (row.delta_abs) |d|
|
|
fmtSignedMoneyBuf(delta_buf, d)
|
|
else
|
|
"n/a";
|
|
|
|
const pct_str: []const u8 = if (row.delta_pct) |p|
|
|
fmtSignedPercentBuf(pct_buf, p)
|
|
else
|
|
// delta_abs missing OR start_value was 0 (division by zero).
|
|
// Both read as "pct undefined" for the user.
|
|
"n/a";
|
|
|
|
const ann_str: []const u8 = if (row.annualized_pct) |a|
|
|
fmtSignedPercentBuf(ann_buf, a)
|
|
else
|
|
"—";
|
|
|
|
return .{
|
|
.label = row.label,
|
|
.delta_str = delta_str,
|
|
.pct_str = pct_str,
|
|
.ann_str = ann_str,
|
|
.style = style,
|
|
};
|
|
}
|
|
|
|
/// Format a signed dollar amount: `"+$1,234.56"`, `"-$1,234.56"`,
|
|
/// `"$0.00"`. Returns a slice of `buf`.
|
|
///
|
|
/// Separate from `fmt.fmtMoneyAbs` (which omits the sign) because the
|
|
/// windows block's Δ column needs the leading sign to distinguish
|
|
/// gains from losses at a glance.
|
|
pub fn fmtSignedMoneyBuf(buf: *[32]u8, value: f64) []const u8 {
|
|
const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else "";
|
|
var tmp: [24]u8 = undefined;
|
|
const abs_str = fmt.fmtMoneyAbs(&tmp, value);
|
|
return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str }) catch "?";
|
|
}
|
|
|
|
/// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`.
|
|
/// Input is a ratio (0.0041 → "+0.41%"). Returns a slice of `buf`.
|
|
///
|
|
/// Sign sits flush against the digit — no internal whitespace
|
|
/// padding. Right-alignment in the column is the caller's job.
|
|
pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
|
|
const pct = ratio * 100.0;
|
|
const prefix: []const u8 = if (pct > 0) "+" else if (pct < 0) "-" else "";
|
|
return std.fmt.bufPrint(buf, "{s}{d:.2}%", .{ prefix, @abs(pct) }) catch "?";
|
|
}
|
|
|
|
// ── Recent-snapshots table cell ──────────────────────────────
|
|
|
|
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
|
|
/// table.
|
|
///
|
|
/// Two sub-columns aligned independently:
|
|
/// - value: right-aligned in `value_subcol_width` chars so digits
|
|
/// stack cleanly across rows of differing magnitude.
|
|
/// - delta: `(±$D.DD)` left-aligned in `delta_subcol_width` chars
|
|
/// so the closing `)` lands at a consistent column.
|
|
/// Separated by a single space.
|
|
///
|
|
/// `delta_opt = null` (first row) renders as `(—)` — the em-dash
|
|
/// signals "no prior row to compare against" without wasting a Δ
|
|
/// column.
|
|
///
|
|
/// `width` parameter is honored but the value/delta sub-widths are
|
|
/// fixed at the module-level constants. If `width >
|
|
/// table_cell_width`, additional left-padding is added (so the
|
|
/// caller's column is wider but the sub-columns stay aligned).
|
|
/// If `width < table_cell_width`, sub-columns may overflow the
|
|
/// caller's slot — caller's problem; defaults match.
|
|
pub fn fmtValueDeltaCell(
|
|
buf: []u8,
|
|
value: f64,
|
|
delta_opt: ?f64,
|
|
width: usize,
|
|
) []const u8 {
|
|
var val_buf: [24]u8 = undefined;
|
|
var delta_inner: [32]u8 = undefined;
|
|
var delta_outer: [40]u8 = undefined;
|
|
const val_str = fmt.fmtMoneyAbs(&val_buf, value);
|
|
const d_inner: []const u8 = if (delta_opt) |d|
|
|
fmtSignedMoneyBuf(&delta_inner, d)
|
|
else
|
|
"—";
|
|
const d_str = std.fmt.bufPrint(&delta_outer, "({s})", .{d_inner}) catch return "?";
|
|
|
|
// Build the composite manually so the sub-column widths are
|
|
// honored exactly. value is right-aligned, delta is left-aligned.
|
|
var inner_buf: [96]u8 = undefined;
|
|
var pos: usize = 0;
|
|
|
|
// Value sub-column: left-pad with spaces if shorter.
|
|
if (val_str.len < value_subcol_width) {
|
|
const lpad = value_subcol_width - val_str.len;
|
|
@memset(inner_buf[pos .. pos + lpad], ' ');
|
|
pos += lpad;
|
|
}
|
|
@memcpy(inner_buf[pos .. pos + val_str.len], val_str);
|
|
pos += val_str.len;
|
|
|
|
// Separator.
|
|
inner_buf[pos] = ' ';
|
|
pos += 1;
|
|
|
|
// Delta sub-column: append, then right-pad with spaces.
|
|
// Pad based on display width — `(—)` is 5 bytes / 3 display cols.
|
|
@memcpy(inner_buf[pos .. pos + d_str.len], d_str);
|
|
pos += d_str.len;
|
|
const d_display = fmt.displayCols(d_str);
|
|
if (d_display < delta_subcol_width) {
|
|
const rpad = delta_subcol_width - d_display;
|
|
@memset(inner_buf[pos .. pos + rpad], ' ');
|
|
pos += rpad;
|
|
}
|
|
|
|
const inner = inner_buf[0..pos];
|
|
|
|
if (inner.len >= width) {
|
|
// Composite is at or beyond the caller's slot; emit as-is.
|
|
const n = @min(inner.len, buf.len);
|
|
@memcpy(buf[0..n], inner[0..n]);
|
|
return buf[0..n];
|
|
}
|
|
|
|
// Caller wants a wider slot; left-pad.
|
|
const pad = width - inner.len;
|
|
if (pad + inner.len > buf.len) {
|
|
const n = @min(inner.len, buf.len);
|
|
@memcpy(buf[0..n], inner[0..n]);
|
|
return buf[0..n];
|
|
}
|
|
@memset(buf[0..pad], ' ');
|
|
@memcpy(buf[pad .. pad + inner.len], inner);
|
|
return buf[0 .. pad + inner.len];
|
|
}
|
|
|
|
// ── Resolution label ─────────────────────────────────────────
|
|
|
|
/// Format the resolution label shown in the recent-snapshots title:
|
|
/// `"(auto - daily)"` when `override` is null (auto; resolved to
|
|
/// `effective`)
|
|
/// `"(daily)"` / `"(weekly)"` / `"(monthly)"` when the user has
|
|
/// explicitly picked a resolution.
|
|
///
|
|
/// Returns a slice of `buf`.
|
|
pub fn fmtResolutionLabel(
|
|
buf: []u8,
|
|
override: ?timeline.Resolution,
|
|
effective: timeline.Resolution,
|
|
) []const u8 {
|
|
return if (override == null)
|
|
std.fmt.bufPrint(buf, "(auto - {s})", .{effective.label()}) catch "?"
|
|
else
|
|
std.fmt.bufPrint(buf, "({s})", .{effective.label()}) catch "?";
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
const Date = @import("../models/date.zig").Date;
|
|
|
|
fn makeWindowStat(
|
|
period: ?@import("../analytics/valuation.zig").HistoricalPeriod,
|
|
label: []const u8,
|
|
start_value: ?f64,
|
|
end_value: f64,
|
|
) timeline.WindowStat {
|
|
const delta_abs: ?f64 = if (start_value) |s| end_value - s else null;
|
|
const delta_pct: ?f64 = blk: {
|
|
const s = start_value orelse break :blk null;
|
|
if (s == 0) break :blk null;
|
|
break :blk (end_value - s) / s;
|
|
};
|
|
return .{
|
|
.period = period,
|
|
.label = label,
|
|
.short_label = label,
|
|
.anchor_date = if (start_value != null) Date.fromYmd(2026, 4, 17) else null,
|
|
.start_value = start_value,
|
|
.end_value = end_value,
|
|
.delta_abs = delta_abs,
|
|
.delta_pct = delta_pct,
|
|
// Tests in this file don't exercise the annualized math —
|
|
// that's covered by `computeWindowSet` tests in
|
|
// analytics/timeline.zig. Pass the same value as
|
|
// `delta_pct` so the renderer-side formatting path is
|
|
// exercised.
|
|
.annualized_pct = delta_pct,
|
|
};
|
|
}
|
|
|
|
// ── buildWindowRowCells ──
|
|
|
|
test "buildWindowRowCells: positive delta" {
|
|
var db: [32]u8 = undefined;
|
|
var pb: [16]u8 = undefined;
|
|
var ab: [16]u8 = undefined;
|
|
const row = makeWindowStat(null, "1 day", 1000, 1100);
|
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
|
try testing.expectEqualStrings("1 day", cells.label);
|
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
|
try testing.expectEqualStrings("+10.00%", cells.pct_str);
|
|
try testing.expectEqualStrings("+10.00%", cells.ann_str);
|
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
|
}
|
|
|
|
test "buildWindowRowCells: negative delta" {
|
|
var db: [32]u8 = undefined;
|
|
var pb: [16]u8 = undefined;
|
|
var ab: [16]u8 = undefined;
|
|
const row = makeWindowStat(null, "1 day", 1000, 900);
|
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
|
try testing.expectEqualStrings("-$100.00", cells.delta_str);
|
|
try testing.expectEqualStrings("-10.00%", cells.pct_str);
|
|
try testing.expectEqual(StyleIntent.negative, cells.style);
|
|
}
|
|
|
|
test "buildWindowRowCells: zero delta renders muted" {
|
|
var db: [32]u8 = undefined;
|
|
var pb: [16]u8 = undefined;
|
|
var ab: [16]u8 = undefined;
|
|
const row = makeWindowStat(null, "1 day", 1000, 1000);
|
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
|
try testing.expectEqualStrings("$0.00", cells.delta_str);
|
|
try testing.expectEqualStrings("0.00%", cells.pct_str);
|
|
try testing.expectEqual(StyleIntent.muted, cells.style);
|
|
}
|
|
|
|
test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
|
|
var db: [32]u8 = undefined;
|
|
var pb: [16]u8 = undefined;
|
|
var ab: [16]u8 = undefined;
|
|
const row = makeWindowStat(null, "1 year", null, 1100);
|
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
|
try testing.expectEqualStrings("n/a", cells.delta_str);
|
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
|
try testing.expectEqualStrings("—", cells.ann_str);
|
|
try testing.expectEqual(StyleIntent.muted, cells.style);
|
|
}
|
|
|
|
test "buildWindowRowCells: zero start_value → pct n/a, delta present" {
|
|
var db: [32]u8 = undefined;
|
|
var pb: [16]u8 = undefined;
|
|
var ab: [16]u8 = undefined;
|
|
const row = makeWindowStat(null, "All-time", 0, 100);
|
|
const cells = buildWindowRowCells(row, &db, &pb, &ab);
|
|
// Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
|
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
|
}
|
|
|
|
// ── fmtSignedMoneyBuf ──
|
|
|
|
test "fmtSignedMoneyBuf: signs + zero + thousands" {
|
|
var buf: [32]u8 = undefined;
|
|
try testing.expectEqualStrings("+$1,234.56", fmtSignedMoneyBuf(&buf, 1234.56));
|
|
try testing.expectEqualStrings("-$1,234.56", fmtSignedMoneyBuf(&buf, -1234.56));
|
|
try testing.expectEqualStrings("$0.00", fmtSignedMoneyBuf(&buf, 0));
|
|
}
|
|
|
|
// ── fmtSignedPercentBuf ──
|
|
|
|
test "fmtSignedPercentBuf: signs + zero + flush digits" {
|
|
var buf: [16]u8 = undefined;
|
|
try testing.expectEqualStrings("+0.41%", fmtSignedPercentBuf(&buf, 0.0041));
|
|
try testing.expectEqualStrings("-1.07%", fmtSignedPercentBuf(&buf, -0.0107));
|
|
try testing.expectEqualStrings("0.00%", fmtSignedPercentBuf(&buf, 0));
|
|
// Pct larger than 100% still renders cleanly.
|
|
try testing.expectEqualStrings("+123.45%", fmtSignedPercentBuf(&buf, 1.2345));
|
|
}
|
|
|
|
// ── fmtValueDeltaCell ──
|
|
|
|
test "fmtValueDeltaCell: sub-columns produce expected layout" {
|
|
var buf: [64]u8 = undefined;
|
|
const s = fmtValueDeltaCell(&buf, 1000, 50, table_cell_width);
|
|
// Composite total = 14 + 1 + 16 = 31 chars.
|
|
try testing.expectEqual(table_cell_width, s.len);
|
|
|
|
// Value sub-column: right-aligned in 14 chars.
|
|
// "$1,000.00" = 9 chars → 5 leading spaces.
|
|
try testing.expectEqualStrings(" $1,000.00", s[0..value_subcol_width]);
|
|
|
|
// Separator space.
|
|
try testing.expectEqual(@as(u8, ' '), s[value_subcol_width]);
|
|
|
|
// Delta sub-column: left-aligned in 16 chars.
|
|
// "(+$50.00)" = 9 chars → 7 trailing spaces.
|
|
const delta_part = s[value_subcol_width + 1 ..];
|
|
try testing.expect(std.mem.startsWith(u8, delta_part, "(+$50.00)"));
|
|
try testing.expectEqual(delta_subcol_width, delta_part.len);
|
|
}
|
|
|
|
test "fmtValueDeltaCell: null delta renders em-dash" {
|
|
var buf: [64]u8 = undefined;
|
|
const s = fmtValueDeltaCell(&buf, 1000, null, table_cell_width);
|
|
// Em-dash bytes: 3 bytes / 1 display col. Cell display width
|
|
// is `table_cell_width` (31), but byte length is 33.
|
|
try testing.expectEqual(@as(usize, table_cell_width + 2), s.len);
|
|
// Delta sub-column starts with `(—)`.
|
|
const delta_part = s[value_subcol_width + 1 ..];
|
|
try testing.expect(std.mem.startsWith(u8, delta_part, "(—)"));
|
|
}
|
|
|
|
test "fmtValueDeltaCell: negative delta" {
|
|
var buf: [64]u8 = undefined;
|
|
const s = fmtValueDeltaCell(&buf, 1000, -50, table_cell_width);
|
|
const delta_part = s[value_subcol_width + 1 ..];
|
|
try testing.expect(std.mem.startsWith(u8, delta_part, "(-$50.00)"));
|
|
}
|
|
|
|
test "fmtValueDeltaCell: large value and delta still align" {
|
|
// Both values right-aligned in their sub-columns; closing
|
|
// parens line up on the right of the cell.
|
|
var buf_a: [64]u8 = undefined;
|
|
const a = fmtValueDeltaCell(&buf_a, 8_633_578.46, 86_757.10, table_cell_width);
|
|
var buf_b: [64]u8 = undefined;
|
|
const b = fmtValueDeltaCell(&buf_b, 6_614_620.99, 1_340_130.92, table_cell_width);
|
|
|
|
// Both should be exactly table_cell_width chars long.
|
|
try testing.expectEqual(table_cell_width, a.len);
|
|
try testing.expectEqual(table_cell_width, b.len);
|
|
|
|
// Both should end at column `value_subcol_width + 1 +
|
|
// delta_subcol_width` — which by construction is true since
|
|
// both have length == table_cell_width.
|
|
}
|
|
|
|
test "fmtValueDeltaCell: caller width smaller than composite still emits full content" {
|
|
// Caller asked for 10 cols; composite needs 31. Emit composite as-is.
|
|
var buf: [64]u8 = undefined;
|
|
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
|
|
try testing.expect(s.len >= 31);
|
|
}
|
|
|
|
// ── fmtResolutionLabel ──
|
|
|
|
test "fmtResolutionLabel: auto reveals the effective resolution" {
|
|
var buf: [32]u8 = undefined;
|
|
try testing.expectEqualStrings("(auto - daily)", fmtResolutionLabel(&buf, null, .daily));
|
|
try testing.expectEqualStrings("(auto - weekly)", fmtResolutionLabel(&buf, null, .weekly));
|
|
try testing.expectEqualStrings("(auto - monthly)", fmtResolutionLabel(&buf, null, .monthly));
|
|
}
|
|
|
|
test "fmtResolutionLabel: explicit override hides the 'auto' prefix" {
|
|
var buf: [32]u8 = undefined;
|
|
try testing.expectEqualStrings("(daily)", fmtResolutionLabel(&buf, .daily, .daily));
|
|
try testing.expectEqualStrings("(weekly)", fmtResolutionLabel(&buf, .weekly, .weekly));
|
|
try testing.expectEqualStrings("(monthly)", fmtResolutionLabel(&buf, .monthly, .monthly));
|
|
}
|