/// Renderer-agnostic view model for the projections display. /// /// Produces pre-formatted text and `StyleIntent` values that both CLI /// and TUI renderers can consume through thin style-mapping adapters. const std = @import("std"); const fmt = @import("../format.zig"); const performance = @import("../analytics/performance.zig"); const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); pub const StyleIntent = fmt.StyleIntent; // ── Layout constants (shared by CLI and TUI) ────────────────── pub const label_width = 32; pub const col_1y = 8; pub const col_3y = 9; pub const col_5y = 9; pub const col_10y = 10; pub const col_week = 9; pub const withdrawal_label_width = 25; pub const withdrawal_col_width = 12; // ── Return row formatting ────────────────────────────────────── /// A single cell in the returns table: formatted text + style. pub const ReturnCell = struct { text: []const u8, style: StyleIntent, }; /// Format a return value into a buffer, returning the styled cell. pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell { if (value) |v| { return .{ .text = performance.formatReturn(buf, v), .style = if (v >= 0) .positive else .negative, }; } return .{ .text = "--", .style = .muted }; } /// A complete row in the benchmark comparison table. pub const ReturnRow = struct { label: []const u8, one_year: ReturnCell, three_year: ReturnCell, five_year: ReturnCell, ten_year: ReturnCell, week: ReturnCell, bold: bool = false, }; /// Build a return row from a ReturnsByPeriod and a label. /// Caller owns the buffers (5 buffers of at least 16 bytes each). pub fn buildReturnRow( label: []const u8, returns: benchmark.ReturnsByPeriod, bufs: *[5][16]u8, bold: bool, ) ReturnRow { return .{ .label = label, .one_year = fmtReturnCell(&bufs[0], returns.one_year), .three_year = fmtReturnCell(&bufs[1], returns.three_year), .five_year = fmtReturnCell(&bufs[2], returns.five_year), .ten_year = fmtReturnCell(&bufs[3], returns.ten_year), .week = fmtReturnCell(&bufs[4], returns.week), .bold = bold, }; } // ── Safe withdrawal formatting ───────────────────────────────── /// A single cell in the withdrawal table. pub const WithdrawalCell = struct { amount_text: []const u8, rate_text: []const u8, }; /// Format a safe withdrawal result into display strings. /// Caller owns both buffers (at least 24 bytes each). pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell { const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount); // Strip trailing ".00" for clean display const clean_amount = if (std.mem.endsWith(u8, money_str, ".00")) money_str[0 .. money_str.len - 3] else money_str; const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%"; return .{ .amount_text = clean_amount, .rate_text = rate_str, }; } /// Format a confidence level label (e.g. "99% safe withdrawal"). pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 { return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??"; } /// Format a horizon column header (e.g. "30 Year"). pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 { return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??"; } // ── Allocation summary ───────────────────────────────────────── /// Format the target allocation note line. /// Returns null if no target is configured. pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?[]const u8 { const target = target_stock_pct orelse return null; const current = current_stock_pct * 100; const diff = current - target; if (@abs(diff) < 2.0) { return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{ target, 100.0 - target, current, }) catch null; } return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{ target, 100.0 - target, current, }) catch null; } /// Format the stock benchmark label with weight (e.g. "SPY (83.8% weight)"). pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 { return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol; } // ── Tests ────────────────────────────────────────────────────── test "fmtReturnCell positive" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, 0.1234); try std.testing.expect(cell.style == .positive); try std.testing.expect(cell.text.len > 0); } test "fmtReturnCell negative" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, -0.05); try std.testing.expect(cell.style == .negative); } test "fmtReturnCell null" { var buf: [16]u8 = undefined; const cell = fmtReturnCell(&buf, null); try std.testing.expect(cell.style == .muted); try std.testing.expectEqualStrings("--", cell.text); } test "fmtWithdrawalCell strips .00" { var abuf: [24]u8 = undefined; var rbuf: [16]u8 = undefined; const cell = fmtWithdrawalCell(&abuf, &rbuf, .{ .confidence = 0.99, .annual_amount = 305000, .withdrawal_rate = 0.0366, }); try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00")); try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null); } test "fmtAllocationNote on target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.768); try std.testing.expect(note != null); try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") != null); } test "fmtAllocationNote off target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.85); try std.testing.expect(note != null); try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") == null); } test "fmtAllocationNote no target" { var buf: [128]u8 = undefined; try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null); } test "fmtBenchmarkLabel" { var buf: [32]u8 = undefined; const label = fmtBenchmarkLabel(&buf, "SPY", 83.8); try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null); try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null); } test "buildReturnRow" { var bufs: [5][16]u8 = undefined; const returns = benchmark.ReturnsByPeriod{ .one_year = 0.15, .three_year = -0.02, .five_year = null, }; const row = buildReturnRow("Test", returns, &bufs, false); try std.testing.expectEqualStrings("Test", row.label); try std.testing.expect(row.one_year.style == .positive); try std.testing.expect(row.three_year.style == .negative); try std.testing.expect(row.five_year.style == .muted); try std.testing.expect(row.bold == false); }