//! `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 Money = @import("../Money.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| std.fmt.bufPrint(delta_buf, "{f}", .{Money.from(d).signed()}) catch "$?" 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 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 = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(value)}) catch "$?"; const d_inner: []const u8 = if (delta_opt) |d| std.fmt.bufPrint(&delta_inner, "{f}", .{Money.from(d).signed()}) catch "$?" 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("../Date.zig"); 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); } // ── 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)); }