From cc5030d70cb09173aa875eb0f1c65dee00849ed5 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 30 Jun 2026 13:42:11 -0700 Subject: [PATCH] dynamic column widths for portfolios --- src/commands/portfolio.zig | 170 ++++++++++-- src/tui.zig | 15 +- src/tui/portfolio_tab.zig | 238 +++++++++++++---- src/tui/review_tab.zig | 3 +- src/views/portfolio_sections.zig | 437 ++++++++++++++++++++++++++----- 5 files changed, 715 insertions(+), 148 deletions(-) diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 7fc07ac..30dc800 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -307,11 +307,22 @@ pub fn display( } } - // Column headers + // Column headers. Column widths fit the rendered content (each + // header label is the floor, the widest datum the ceiling); see + // views.computeWidths. Computed once and threaded through the rows, + // totals, and watchlist so every line aligns. + const w = views.computeWidths( + summary.allocations, + portfolio.lots, + summary.total_value, + summary.unrealized_gain_loss, + watch_symbols, + watch_prices, + ); try out.print("\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(pl.header, pl.header_labels); - try out.print(pl.separator, pl.separator_fills); + try views.writeHeader(out, w); + try views.writeSeparator(out, w); try cli.reset(out, color); // Position rows with lot detail @@ -343,13 +354,21 @@ pub fn display( } if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING); - try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} ", .{ - a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(pl.price_w), - }); - try out.print("{f}", .{Money.from(a.current_price).padRight(pl.price_w)}); - try out.print(" {f} ", .{Money.from(a.market_value).padRight(pl.value_w)}); + try out.writeAll(" "); + try views.writeCol(out, a.display_symbol, w.symbol_w, false); + try out.writeByte(' '); + var shares_buf: [48]u8 = undefined; + const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{a.shares}) catch "?"; + try views.writeCol(out, shares_str, w.shares_w, true); + try out.writeByte(' '); + try out.print("{f}", .{Money.from(a.avg_cost).padRight(w.price_w)}); + try out.writeByte(' '); + try out.print("{f}", .{Money.from(a.current_price).padRight(w.price_w)}); + try out.writeByte(' '); + try out.print("{f}", .{Money.from(a.market_value).padRight(w.value_w)}); + try out.writeByte(' '); try cli.setGainLoss(out, color, a.unrealized_gain_loss); - try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(pl.gainloss_w - 1) }); + try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(w.gainloss_w - 1) }); if (a.is_manual_price) { try cli.setFg(out, color, cli.CLR_WARNING); } else { @@ -383,13 +402,13 @@ pub fn display( if (!has_drip) { // No DRIP: show all individually for (lots_for_sym.items) |lot| { - try printLotRow(as_of, out, color, lot, a.current_price); + try printLotRow(as_of, out, color, lot, a.current_price, w); } } else { // Show non-DRIP lots individually for (lots_for_sym.items) |lot| { if (!lot.drip) { - try printLotRow(as_of, out, color, lot, a.current_price); + try printLotRow(as_of, out, color, lot, a.current_price, w); } } @@ -409,15 +428,24 @@ pub fn display( } // Totals line - try cli.printFg(out, color, cli.CLR_MUTED, pl.total_sep, pl.total_sep_fills); + try cli.setFg(out, color, cli.CLR_MUTED); + try views.writeTotalSeparator(out, w); + try cli.reset(out, color); { const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; - try out.print(pl.total, .{ - "", "", "", "TOTAL", Money.from(summary.total_value).padRight(pl.value_w), - }); + try out.writeAll(" "); + try views.writeCol(out, "", w.symbol_w, false); // blank symbol + try out.writeByte(' '); + try views.writeCol(out, "", w.shares_w, true); // blank shares + try out.writeByte(' '); + try views.writeCol(out, "", w.price_w, true); // blank avg cost + try out.writeByte(' '); + try views.writeCol(out, "TOTAL", w.price_w, true); // "TOTAL" in the price column + try out.writeByte(' '); + try out.print("{f} ", .{Money.from(summary.total_value).padRight(w.value_w)}); try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), - Money.from(gl_abs).padRight(pl.gainloss_w - 1), + Money.from(gl_abs).padRight(w.gainloss_w - 1), }); try out.print(" " ++ pl.weight_str ++ "\n", .{"100.0%"}); } @@ -589,7 +617,11 @@ pub fn display( std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?" else "--"; - try out.print(" " ++ pl.symbol_spec ++ " " ++ pl.price_str ++ "\n", .{ sym, ps }); + try out.writeAll(" "); + try views.writeCol(out, sym, w.symbol_w, false); + try out.writeByte(' '); + try views.writeCol(out, ps, w.price_w, true); + try out.writeByte('\n'); } } @@ -602,7 +634,7 @@ pub fn display( try out.print("\n", .{}); } -pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void { +pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64, w: views.PositionsWidths) !void { const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed"; const acct_col: []const u8 = lot.account orelse ""; @@ -612,11 +644,31 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin const lot_gl_abs = if (gl >= 0) gl else -gl; const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " {f} " ++ pl.price_str ++ " {f} ", .{ - status_str, lot.shares, Money.from(lot.open_price).padRight(pl.price_w), "", Money.from(lot.shares * use_price).padRight(pl.value_w), - }); - try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(pl.gainloss_w - 1) }); - try cli.printFg(out, color, cli.CLR_MUTED, " " ++ pl.weight_str ++ " {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col }); + // Muted run: prefix (lots are indented one level past positions) + // through the market-value cell. + try cli.setFg(out, color, cli.CLR_MUTED); + try out.writeAll(" "); + try views.writeCol(out, status_str, w.symbol_w, false); + try out.writeByte(' '); + var shares_buf: [48]u8 = undefined; + const shares_str = std.fmt.bufPrint(&shares_buf, "{d:.1}", .{lot.shares}) catch "?"; + try views.writeCol(out, shares_str, w.shares_w, true); + try out.writeByte(' '); + try out.print("{f}", .{Money.from(lot.open_price).padRight(w.price_w)}); + try out.writeByte(' '); + try views.writeCol(out, "", w.price_w, true); // blank current-price cell + try out.writeByte(' '); + try out.print("{f}", .{Money.from(lot.shares * use_price).padRight(w.value_w)}); + try out.writeByte(' '); + try cli.reset(out, color); + // Colored gain/loss cell. + try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(w.gainloss_w - 1) }); + // Muted run: blank weight cell, then date / indicator / account. + try cli.setFg(out, color, cli.CLR_MUTED); + try out.writeByte(' '); + try views.writeCol(out, "", w.weight_w, true); + try out.print(" {f} {s} {s}\n", .{ lot.open_date, indicator, acct_col }); + try cli.reset(out, color); } // ── Tests ──────────────────────────────────────────────────── @@ -749,11 +801,13 @@ test "display widens columns for large crypto values" { // Full 8-char symbol present (not truncated). try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD") != null); - // Symbol column is wide enough that DOGE-USD does not butt up against - // the next column: it is followed by >= 2 spaces (in-column padding - // plus the separator). At the old 7-wide column there would be only - // the single separator space, so this catches a narrow-column regression. - try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD ") != null); + // Fit-to-content: the Symbol column sizes to the longest symbol + // (DOGE-USD = 8 cols) and Shares to "10000.0" (7 cols), so the full + // symbol renders followed by exactly the 1-col separator and then + // the right-justified Shares value - no truncation, no wasted + // padding. (The old fixed 10-wide Symbol column left 2 trailing pad + // spaces here; fit-to-content removes them.) + try testing.expect(std.mem.indexOf(u8, out, "DOGE-USD 10000.0") != null); // Large share count, high price, and large market value render in full. try testing.expect(std.mem.indexOf(u8, out, "10000.0") != null); try testing.expect(std.mem.indexOf(u8, out, "$42,000.00") != null); @@ -806,6 +860,66 @@ test "display widens columns for large crypto values" { try testing.expectEqual(hdr_acct.?, row_acct.?); } +test "display tightens columns for a small portfolio" { + // Counterpart to the crypto test: small share counts and prices mean + // every dynamic column collapses to its header-label minimum, so the + // table is compact rather than padded out for hypothetical large + // values. The TOTAL row still aligns with the data row. + var buf: [8192]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + + var lots = [_]zfin.Lot{ + .{ .symbol = "IBM", .shares = 10, .open_date = zfin.Date.fromYmd(2024, 1, 2), .open_price = 150.0, .account = "Sample Account" }, + }; + var portfolio = testPortfolio(&lots); + + var positions = [_]zfin.Position{ + .{ .symbol = "IBM", .shares = 10, .avg_cost = 150.0, .total_cost = 1500.0, .open_lots = 1, .closed_lots = 0, .realized_gain_loss = 0 }, + }; + + var allocs = [_]zfin.valuation.Allocation{ + .{ .symbol = "IBM", .display_symbol = "IBM", .shares = 10, .avg_cost = 150.0, .current_price = 155.0, .market_value = 1550.0, .cost_basis = 1500.0, .weight = 1.0, .unrealized_gain_loss = 50.0, .unrealized_return = 0.0333 }, + }; + const summary = testSummary(&allocs); + + var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); + defer candle_map.deinit(); + const pf_data = testPortfolioData(summary, candle_map); + + var watch_prices = std.StringHashMap(f64).init(testing.allocator); + defer watch_prices.deinit(); + const watch_syms: []const []const u8 = &.{}; + + try display(testing.allocator, &w, false, "small.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices, zfin.Date.fromYmd(2026, 5, 8), .rollup); + const out = w.buffered(); + + // All dynamic columns are at their header-label minimums, so the + // labels sit flush against each other with a single separator space: + // "Symbol"(6) + " " + "Shares"(6) + " " + "Avg Cost"(8). With the old + // fixed widths (Symbol=10, Shares=12) there would be many spaces here, + // so this pins the "tighten, don't waste space" behavior. + try testing.expect(std.mem.indexOf(u8, out, "Symbol Shares Avg Cost") != null); + + // Data row market value aligns with the TOTAL row market value. + var data_col: ?usize = null; + var total_col: ?usize = null; + var lit = std.mem.splitScalar(u8, out, '\n'); + while (lit.next()) |line| { + const at = std.mem.indexOf(u8, line, "$1,550.00") orelse continue; + if (std.mem.indexOf(u8, line, "IBM") != null) { + data_col = at; + } else if (std.mem.indexOf(u8, line, "TOTAL") != null) { + total_col = at; + } + } + try testing.expect(data_col != null); + try testing.expect(total_col != null); + try testing.expectEqual(data_col.?, total_col.?); + + // No ANSI codes when color is disabled. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); +} + test "display with watchlist" { var buf: [8192]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); diff --git a/src/tui.zig b/src/tui.zig index 4fce302..c295949 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -47,7 +47,12 @@ const ascii_g = blk: { /// The indicator (▲/▼, 3 bytes, 1 display column) replaces a padding space so total /// display width stays constant. Indicator always appears on the left side. /// `left` controls text alignment (left-aligned vs right-aligned). -pub fn colLabel(buf: []u8, name: []const u8, comptime col_width: usize, left: bool, indicator: ?[]const u8) []const u8 { +pub fn colLabel(buf: []u8, name: []const u8, col_width: usize, left: bool, indicator: ?[]const u8) []const u8 { + // `col_width` is dynamic (computed per render), so guard against a + // width that exceeds the caller's buffer rather than overflowing. + // Falling back to the unpadded name is safe; callers size buffers + // generously so this only trips for pathological widths. + if (col_width > buf.len) return name; const ind = indicator orelse { // No indicator: plain padded label if (left) { @@ -2746,6 +2751,14 @@ test "colLabel plain left-aligned" { try testing.expectEqual(@as(usize, 10), result.len); } +test "colLabel: width exceeding the buffer falls back to the unpadded name" { + // Dynamic column widths can exceed a caller's buffer; colLabel must + // return the name unchanged rather than overflow. + var buf: [8]u8 = undefined; + const result = colLabel(&buf, "Name", 100, true, null); + try testing.expectEqualStrings("Name", result); +} + test "colLabel plain right-aligned" { var buf: [32]u8 = undefined; const result = colLabel(&buf, "Price", 10, false, null); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 3f610a7..4759eb6 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -4,7 +4,8 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); -/// Main holdings-table column layout (widths + per-column specs). +/// Holdings-table layout: fixed knobs (weight/date specs) + minimum +/// widths. The dynamic widths come from `views.computeWidths`. const pl = views.PositionsLayout; const theme = @import("theme.zig"); const tui = @import("../tui.zig"); @@ -16,13 +17,13 @@ const colLabel = tui.colLabel; // Portfolio column layout (display columns). // Row shape: prefix(4) + sym + shares + avgcost + price + mv + gl + weight + date + account. -// Every non-prefix column width is owned by views/portfolio_sections.zig -// (PositionsLayout: symbol_w, shares_w, price_w, value_w, gainloss_w, -// weight_w, date_w); each column adds a 1-col separator space. The -// row/header format strings and the col_end_* hit-test offsets below all -// derive from those same widths, so they can't drift apart. +// The non-prefix column widths are computed per render by +// views.computeWidths (fit-to-content: header-label floor, widest-datum +// ceiling) and cached in State.col_widths; each column adds a 1-col +// separator space. The header, data rows, and the click-to-sort +// hit-test offsets (`columnOffsets`) all read those same widths, so +// they can't drift apart. const prefix_cols: usize = 4; -const sw: usize = pl.symbol_w; // ── Portfolio-specific types ────────────────────────────────── @@ -278,6 +279,12 @@ pub const State = struct { /// captured when streaming starts so the per-tick snapshot has /// stable key strings. Freed (and emptied) when streaming stops. stream_syms: [][]const u8 = &.{}, + /// Holdings-table column widths from the most recent render + /// (`views.computeWidths`). Cached here so `handleMouse`'s + /// click-to-sort hit test uses the exact same column boundaries the + /// last frame drew. Defaults to the per-column minimums, which is a + /// valid layout for the pre-first-render case. + col_widths: views.PositionsWidths = .{}, }; // ── Tab framework contract ──────────────────────────────────── @@ -678,22 +685,23 @@ pub const tab = struct { // Click on the column-header row -> sort by that column. if (state.header_lines > 0 and content_row == state.header_lines - 1) { const col: usize = @intCast(mouse.col); + const off = columnOffsets(state.col_widths); const new_field: ?PortfolioSortField = - if (col < col_end_symbol) + if (col < off.symbol) .symbol - else if (col < col_end_shares) + else if (col < off.shares) .shares - else if (col < col_end_avg_cost) + else if (col < off.avg_cost) .avg_cost - else if (col < col_end_price) + else if (col < off.price) .price - else if (col < col_end_market_value) + else if (col < off.market_value) .market_value - else if (col < col_end_gain_loss) + else if (col < off.gain_loss) .gain_loss - else if (col < col_end_weight) + else if (col < off.weight) .weight - else if (col < col_end_date) + else if (col < off.date) null // Date (not sortable) else .account; @@ -780,21 +788,46 @@ fn toggleExpandAtCursor(state: *State, app: *App) void { } } -/// Cumulative column end positions for click-to-sort hit testing. -/// Each offset is the previous column's end plus this column's content -/// width (from format.zig) plus the 1-col separator space, so the -/// hit-test grid stays locked to the row/header format strings. -const col_end_symbol: usize = prefix_cols + sw + 1; -const col_end_shares: usize = col_end_symbol + pl.shares_w + 1; -const col_end_avg_cost: usize = col_end_shares + pl.price_w + 1; -const col_end_price: usize = col_end_avg_cost + pl.price_w + 1; -const col_end_market_value: usize = col_end_price + pl.value_w + 1; -const col_end_gain_loss: usize = col_end_market_value + pl.gainloss_w + 1; -const col_end_weight: usize = col_end_gain_loss + pl.weight_w + 1; -const col_end_date: usize = col_end_weight + pl.date_w + 1; +/// Cumulative column end positions for click-to-sort hit testing and +/// the gain/loss alt-style range. Each offset is the previous column's +/// end plus this column's content width plus the 1-col separator space, +/// so the hit-test grid stays locked to the rendered rows. Computed +/// from the same `PositionsWidths` the rows are drawn with. +const ColumnOffsets = struct { + symbol: usize, + shares: usize, + avg_cost: usize, + price: usize, + market_value: usize, + gain_loss: usize, + weight: usize, + date: usize, + /// Gain/loss column start (== market_value end); used for alt-style + /// coloring of the gain/loss cell. + gl_start: usize, +}; -// Gain/loss column start position (used for alt-style coloring) -const gl_col_start: usize = col_end_market_value; +fn columnOffsets(w: views.PositionsWidths) ColumnOffsets { + const symbol = prefix_cols + w.symbol_w + 1; + const shares = symbol + w.shares_w + 1; + const avg_cost = shares + w.price_w + 1; + const price = avg_cost + w.price_w + 1; + const market_value = price + w.value_w + 1; + const gain_loss = market_value + w.gainloss_w + 1; + const weight = gain_loss + w.weight_w + 1; + const date = weight + w.date_w + 1; + return .{ + .symbol = symbol, + .shares = shares, + .avg_cost = avg_cost, + .price = price, + .market_value = market_value, + .gain_loss = gain_loss, + .weight = weight, + .date = date, + .gl_start = market_value, + }; +} /// Map a semantic StyleIntent to a platform-specific vaxis style. fn mapIntent(th: theme.Theme, intent: fmt.StyleIntent) vaxis.Style { @@ -1657,26 +1690,54 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va // Empty line before header try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + // Fit-to-content column widths for this frame. Watch symbols come + // from the already-built rows, so the account filter (which hides + // the watchlist) is reflected automatically. Cache the widths in + // State so handleMouse's click-to-sort hit test uses the exact same + // column boundaries this frame draws. (Under an account filter the + // unfiltered allocation values may slightly over-size a column; + // that only ever over-pads, never misaligns - see computeWidths.) + const cw = blk: { + if (app.portfolio.summary) |s| { + const lots: []const zfin.Lot = if (app.portfolio.file) |pf| pf.lots else &.{}; + var watch_syms: std.ArrayList([]const u8) = .empty; + for (state.rows.items) |row| { + if (row.kind == .watchlist) try watch_syms.append(arena, row.symbol); + } + break :blk views.computeWidths( + s.allocations, + lots, + s.total_value, + s.unrealized_gain_loss, + watch_syms.items, + app.portfolio.watchlist_prices, + ); + } else break :blk views.PositionsWidths{}; + }; + state.col_widths = cw; + const off = columnOffsets(cw); + // Column header (4-char prefix to match arrow(2)+star(2) in data rows) // Active sort column gets a sort indicator within the column width const sf = state.sort_field; const si = state.sort_dir.indicator(); // Build column labels with indicator embedded in padding // Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price" - var sym_hdr_buf: [16]u8 = undefined; - var shr_hdr_buf: [16]u8 = undefined; - var avg_hdr_buf: [16]u8 = undefined; - var prc_hdr_buf: [16]u8 = undefined; - var mv_hdr_buf: [24]u8 = undefined; - var gl_hdr_buf: [24]u8 = undefined; - var wt_hdr_buf: [16]u8 = undefined; - const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", pl.symbol_w, true, if (sf == .symbol) si else null); - const shr_hdr = colLabel(&shr_hdr_buf, "Shares", pl.shares_w, false, if (sf == .shares) si else null); - const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", pl.price_w, false, if (sf == .avg_cost) si else null); - const prc_hdr = colLabel(&prc_hdr_buf, "Price", pl.price_w, false, if (sf == .price) si else null); - const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", pl.value_w, false, if (sf == .market_value) si else null); - const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", pl.gainloss_w, false, if (sf == .gain_loss) si else null); - const wt_hdr = colLabel(&wt_hdr_buf, "Weight", pl.weight_w, false, if (sf == .weight) si else null); + // Buffers are generous (64) because column widths are now dynamic. + var sym_hdr_buf: [64]u8 = undefined; + var shr_hdr_buf: [64]u8 = undefined; + var avg_hdr_buf: [64]u8 = undefined; + var prc_hdr_buf: [64]u8 = undefined; + var mv_hdr_buf: [64]u8 = undefined; + var gl_hdr_buf: [64]u8 = undefined; + var wt_hdr_buf: [64]u8 = undefined; + const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", cw.symbol_w, true, if (sf == .symbol) si else null); + const shr_hdr = colLabel(&shr_hdr_buf, "Shares", cw.shares_w, false, if (sf == .shares) si else null); + const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", cw.price_w, false, if (sf == .avg_cost) si else null); + const prc_hdr = colLabel(&prc_hdr_buf, "Price", cw.price_w, false, if (sf == .price) si else null); + const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", cw.value_w, false, if (sf == .market_value) si else null); + const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", cw.gainloss_w, false, if (sf == .gain_loss) si else null); + const wt_hdr = colLabel(&wt_hdr_buf, "Weight", cw.weight_w, false, if (sf == .weight) si else null); const acct_ind: []const u8 = if (sf == .account) si else ""; const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} " ++ pl.date_str ++ " {s}{s}", .{ @@ -1760,8 +1821,29 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va else a.weight; - const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{ - arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col, + // Pad each dynamic cell to this frame's column + // widths (weight + date stay fixed-spec). Symbol + // is left-justified; the numeric cells right. + var sym_buf: [64]u8 = undefined; + const sym_cell = blk: { + const sym_src = std.fmt.bufPrint(&sym_buf, "{s}", .{a.display_symbol}) catch a.display_symbol; + break :blk fmt.padRightToCols(&sym_buf, sym_src, cw.symbol_w); + }; + var shr_raw_buf: [48]u8 = undefined; + const shr_raw = std.fmt.bufPrint(&shr_raw_buf, "{d:.1}", .{display_shares}) catch "?"; + var shr_buf: [64]u8 = undefined; + const shr_cell = fmt.padLeftToCols(&shr_buf, shr_raw, cw.shares_w); + var cost_pad: [64]u8 = undefined; + const cost_cell = fmt.padLeftToCols(&cost_pad, cost_str, cw.price_w); + var prc_pad: [64]u8 = undefined; + const prc_cell = fmt.padLeftToCols(&prc_pad, price_str, cw.price_w); + var mv_pad: [64]u8 = undefined; + const mv_cell = fmt.padLeftToCols(&mv_pad, mv_str, cw.value_w); + var gl_pad: [64]u8 = undefined; + const gl_cell = fmt.padLeftToCols(&gl_pad, pnl_str, cw.gainloss_w); + + const text = try std.fmt.allocPrint(arena, "{s}{s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_num ++ " " ++ pl.date_str ++ " {s}", .{ + arrow, star, sym_cell, shr_cell, cost_cell, prc_cell, mv_cell, gl_cell, display_weight * 100.0, date_col, acct_col, }); // base: neutral text for main cols, green/red only for gain/loss col @@ -1773,8 +1855,8 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .text = text, .style = base_style, .alt_style = gl_style, - .alt_start = gl_col_start, - .alt_end = gl_col_start + pl.gainloss_w, + .alt_start = off.gl_start, + .alt_end = off.gl_start + cw.gainloss_w, }); } } @@ -1808,8 +1890,26 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); const acct_col: []const u8 = lot.account orelse ""; - const text = try std.fmt.allocPrint(arena, " " ++ pl.symbol_spec ++ " " ++ pl.shares_num ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{ - status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col, + + var lot_sym_buf: [64]u8 = undefined; + const lot_sym_cell = blk: { + const sym_src = std.fmt.bufPrint(&lot_sym_buf, "{s}", .{status_str}) catch status_str; + break :blk fmt.padRightToCols(&lot_sym_buf, sym_src, cw.symbol_w); + }; + var lot_shr_raw: [48]u8 = undefined; + const lot_shr_raw_s = std.fmt.bufPrint(&lot_shr_raw, "{d:.1}", .{lot.shares}) catch "?"; + var lot_shr_buf: [64]u8 = undefined; + const lot_shr_cell = fmt.padLeftToCols(&lot_shr_buf, lot_shr_raw_s, cw.shares_w); + var lot_cost_pad: [64]u8 = undefined; + const lot_cost_cell = fmt.padLeftToCols(&lot_cost_pad, lot_price_str, cw.price_w); + var lot_prc_pad: [64]u8 = undefined; + const lot_prc_cell = fmt.padLeftToCols(&lot_prc_pad, "", cw.price_w); // blank current-price cell + var lot_mv_pad: [64]u8 = undefined; + const lot_mv_cell = fmt.padLeftToCols(&lot_mv_pad, lot_mv_str, cw.value_w); + var lot_gl_pad: [64]u8 = undefined; + const lot_gl_cell = fmt.padLeftToCols(&lot_gl_pad, lot_gl_str, cw.gainloss_w); + const text = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str ++ " {s}", .{ + lot_sym_cell, lot_shr_cell, lot_cost_cell, lot_prc_cell, lot_mv_cell, lot_gl_cell, "", lot_date_col, acct_col, }); const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle(); @@ -1817,8 +1917,8 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va .text = text, .style = base_style, .alt_style = gl_col_style, - .alt_start = gl_col_start, - .alt_end = gl_col_start + pl.gainloss_w, + .alt_start = off.gl_start, + .alt_end = off.gl_start + cw.gainloss_w, }); } }, @@ -1829,8 +1929,23 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va else "--"; const star2: []const u8 = if (is_active_sym) "* " else " "; - const text = try std.fmt.allocPrint(arena, " {s}" ++ pl.symbol_spec ++ " " ++ pl.shares_str ++ " " ++ pl.price_str ++ " " ++ pl.price_str ++ " " ++ pl.value_str ++ " " ++ pl.gainloss_str ++ " " ++ pl.weight_str ++ " " ++ pl.date_str, .{ - star2, row.symbol, "--", "--", ps, "--", "--", "watch", "", + var w_sym_buf: [64]u8 = undefined; + const w_sym_cell = blk: { + const sym_src = std.fmt.bufPrint(&w_sym_buf, "{s}", .{row.symbol}) catch row.symbol; + break :blk fmt.padRightToCols(&w_sym_buf, sym_src, cw.symbol_w); + }; + var w_shr_buf: [64]u8 = undefined; + const w_shr_cell = fmt.padLeftToCols(&w_shr_buf, "--", cw.shares_w); + var w_avg_buf: [64]u8 = undefined; + const w_avg_cell = fmt.padLeftToCols(&w_avg_buf, "--", cw.price_w); + var w_prc_buf: [64]u8 = undefined; + const w_prc_cell = fmt.padLeftToCols(&w_prc_buf, ps, cw.price_w); + var w_mv_buf: [64]u8 = undefined; + const w_mv_cell = fmt.padLeftToCols(&w_mv_buf, "--", cw.value_w); + var w_gl_buf: [64]u8 = undefined; + const w_gl_cell = fmt.padLeftToCols(&w_gl_buf, "--", cw.gainloss_w); + const text = try std.fmt.allocPrint(arena, " {s}{s} {s} {s} {s} {s} {s} " ++ pl.weight_str ++ " " ++ pl.date_str, .{ + star2, w_sym_cell, w_shr_cell, w_avg_cell, w_prc_cell, w_mv_cell, w_gl_cell, "watch", "", }); const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = row_style }); @@ -2711,6 +2826,23 @@ test "ensureCursorVisible: zero visible height is a no-op for the lower bound" { try testing.expectEqual(@as(usize, 0), scroll); } +test "columnOffsets: cumulative boundaries derived from the column widths" { + // gl_start == market_value end, and each boundary is the prior one + // plus the column's width plus a 1-col separator. These offsets back + // the click-to-sort hit test and the gain/loss alt-style range. + const w: views.PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 }; + const off = columnOffsets(w); + try testing.expectEqual(prefix_cols + w.symbol_w + 1, off.symbol); + try testing.expectEqual(off.symbol + w.shares_w + 1, off.shares); + try testing.expectEqual(off.shares + w.price_w + 1, off.avg_cost); + try testing.expectEqual(off.avg_cost + w.price_w + 1, off.price); + try testing.expectEqual(off.price + w.value_w + 1, off.market_value); + try testing.expectEqual(off.market_value + w.gainloss_w + 1, off.gain_loss); + try testing.expectEqual(off.gain_loss + w.weight_w + 1, off.weight); + try testing.expectEqual(off.weight + w.date_w + 1, off.date); + try testing.expectEqual(off.market_value, off.gl_start); +} + test "gatherStreamSymbols: held first, watchlist deduped against held" { const a = testing.allocator; const held = [_][]const u8{ "AAPL", "SPY" }; diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig index f551789..c03e7af 100644 --- a/src/tui/review_tab.zig +++ b/src/tui/review_tab.zig @@ -1796,8 +1796,7 @@ fn buildHeaderLine( if (sort_field == col_sf) break :blk sort_dir.indicator(); break :blk null; }; - // `comptime` widths required by colLabel. - const w = comptime col.width(); + const w = col.width(); var buf: [64]u8 = undefined; const left_align = (col == .symbol or col == .sector); const lbl = tui.colLabel(&buf, col.header(), w, left_align, ind); diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index fa3cb64..010937b 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -9,87 +9,258 @@ const Lot = @import("../models/portfolio.zig").Lot; const Date = @import("../Date.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); +const Allocation = @import("../analytics/valuation.zig").Allocation; // ── Positions (main holdings table) ─────────────────────────── -/// Column layout for the main holdings table - the portfolio's primary -/// table, shared by the CLI (commands/portfolio.zig) and the TUI -/// (tui/portfolio_tab.zig). This is the single source of truth for the -/// column widths, so the CLI header/separator/rows, the TUI header/rows, -/// and the TUI click-to-sort hit-test offsets (portfolio_tab.col_end_*) -/// all derive from the same numbers and can't drift apart. +/// Fixed knobs for the main holdings table. The numeric column widths +/// are NOT fixed any more: they're computed per render by +/// `computeWidths` so the table fits its content (each column's header +/// label is the floor; the widest rendered datum is the ceiling). This +/// struct holds only the parts that never vary: /// -/// Unlike the Options / CDs layouts, the rows don't collapse into one -/// `data_row` string: the gain/loss cell is colored (so the CLI splits -/// the row across several prints) and the shares / weight cells render a -/// float inline in data rows but a pre-formatted string in headers and -/// the TUI's pre-rendered cells. So the per-column specs are exposed -/// individually alongside the composed header / separator / total -/// strings. +/// - The two columns that never need to grow: Weight (bounded to +/// "100.0%") and Date (always "YYYY-MM-DD" plus a one-col ST/LT +/// indicator). +/// - The minimum width of each dynamic column, which equals the +/// width of that column's header label so the label is never +/// truncated (even for an empty portfolio). +/// - The fixed weight/date format specs and the header labels. +/// +/// `PositionsWidths` (via `computeWidths`) is the single source of +/// truth for the dynamic widths. It's consumed by the CLI +/// (commands/portfolio.zig), the TUI rows + header +/// (tui/portfolio_tab.zig), and the TUI click-to-sort hit-test offsets, +/// so they all size identically and can't drift apart. pub const PositionsLayout = struct { const cp = std.fmt.comptimePrint; - // Column content widths. The 1-col separating space between columns - // is added at layout time, not included here. - pub const symbol_w = 10; - pub const shares_w = 12; - pub const price_w = 12; // avg cost and current price - pub const value_w = 16; // market value - pub const gainloss_w = 14; // sign + amount + // Fixed columns (never grow). The 1-col separating space between + // columns is added at layout time, not included here. pub const weight_w = 8; // "NNN.N%" - pub const date_w = 13; + pub const date_w = 13; // "YYYY-MM-DD" + " " + ST/LT indicator pub const account_w = 8; // separator rule only; header/data are natural width - // Per-column format specs. `symbol_spec` and `date_str` left-justify - // their text columns (symbol and date read left-to-right, so their - // headers sit over the start of the data); the other specs right- - // justify their numeric columns. `shares_num` / `weight_num` render - // a float in place (data rows); the `*_str` specs right-justify a - // pre-formatted string (headers and the TUI's pre-rendered cells). - pub const symbol_spec = cp("{{s:<{d}}}", .{symbol_w}); - pub const shares_num = cp("{{d:>{d}.1}}", .{shares_w}); + // Minimum width of each dynamic column = its header label width, so + // the label never truncates. (min_symbol_w also covers the lot-row + // status text "closed".) + pub const min_symbol_w = "Symbol".len; // 6 + pub const min_shares_w = "Shares".len; // 6 + pub const min_price_w = "Avg Cost".len; // 8 (wider of "Avg Cost" / "Price") + pub const min_value_w = "Market Value".len; // 12 + pub const min_gainloss_w = "Gain/Loss".len; // 9 + + // Fixed-column format specs (weight + date never vary). `weight_num` + // renders a float in place (data rows); `weight_str` right-justifies + // a pre-formatted string (headers, the TUI's pre-rendered cells); + // `date_str` left-justifies the date text. pub const weight_num = cp("{{d:>{d}.1}}%", .{weight_w - 1}); - pub const shares_str = cp("{{s:>{d}}}", .{shares_w}); - pub const price_str = cp("{{s:>{d}}}", .{price_w}); - pub const value_str = cp("{{s:>{d}}}", .{value_w}); - pub const gainloss_str = cp("{{s:>{d}}}", .{gainloss_w}); pub const weight_str = cp("{{s:>{d}}}", .{weight_w}); pub const date_str = cp("{{s:<{d}}}", .{date_w}); - // CLI header + dashed separator (9 columns, see header_labels). - pub const header = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++ - " " ++ price_str ++ " " ++ value_str ++ " " ++ gainloss_str ++ - " " ++ weight_str ++ " " ++ date_str ++ " {s}\n"; pub const header_labels = .{ "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account" }; - pub const separator = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++ - " " ++ cp("{{s:->{d}}}", .{shares_w}) ++ - " " ++ cp("{{s:->{d}}}", .{price_w}) ++ - " " ++ cp("{{s:->{d}}}", .{price_w}) ++ - " " ++ cp("{{s:->{d}}}", .{value_w}) ++ - " " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++ - " " ++ cp("{{s:->{d}}}", .{weight_w}) ++ - " " ++ cp("{{s:->{d}}}", .{date_w}) ++ - " " ++ cp("{{s:->{d}}}", .{account_w}) ++ "\n"; - pub const separator_fills = .{ "", "", "", "", "", "", "", "", "" }; - - // CLI TOTAL row: a sum rule spanning Symbol..Weight, then the totals - // row. `total` blanks the symbol / shares / avg-cost cells, right- - // justifies "TOTAL" in the price cell, and renders the market-value - // cell (4 string args + the market-value `{f}`); the caller prints - // the gain/loss and weight cells separately because gain/loss needs - // color. - pub const total_sep = " " ++ cp("{{s:->{d}}}", .{symbol_w}) ++ - " " ++ cp("{{s:->{d}}}", .{shares_w}) ++ - " " ++ cp("{{s:->{d}}}", .{price_w}) ++ - " " ++ cp("{{s:->{d}}}", .{price_w}) ++ - " " ++ cp("{{s:->{d}}}", .{value_w}) ++ - " " ++ cp("{{s:->{d}}}", .{gainloss_w}) ++ - " " ++ cp("{{s:->{d}}}", .{weight_w}) ++ "\n"; - pub const total_sep_fills = .{ "", "", "", "", "", "", "" }; - pub const total = " " ++ symbol_spec ++ " " ++ shares_str ++ " " ++ price_str ++ - " " ++ price_str ++ " {f} "; }; +/// Per-render widths for the holdings table's dynamic columns, plus the +/// two fixed ones mirrored in so callers have one place that answers +/// "how wide is column X". Field defaults are the per-column minimums, +/// so a default-initialized value (e.g. a TUI state's cached widths +/// before the first render) still yields a valid, non-truncating +/// layout. Populate via `computeWidths`. +pub const PositionsWidths = struct { + symbol_w: usize = PositionsLayout.min_symbol_w, + shares_w: usize = PositionsLayout.min_shares_w, + price_w: usize = PositionsLayout.min_price_w, + value_w: usize = PositionsLayout.min_value_w, + gainloss_w: usize = PositionsLayout.min_gainloss_w, + weight_w: usize = PositionsLayout.weight_w, + date_w: usize = PositionsLayout.date_w, +}; + +/// Compute the holdings-table column widths from the data that will be +/// rendered: every allocation row, every stock lot row (lot +/// `open_price` lands in the Avg Cost column, and a single lot's +/// gain/loss can exceed the netted position/total gain/loss when lots +/// have opposite signs, so both must be observed), the TOTAL row, and +/// the watchlist rows (which share the Symbol / Price columns). Each +/// width starts at its header-label minimum and grows to fit. +/// +/// `watch_prices` is keyed by symbol; absent prices simply don't widen +/// the Price column. Pass an empty `watch_syms` to exclude the +/// watchlist (the TUI hides it under an account filter). +/// +/// Note: callers that show a filtered subset (the TUI account filter) +/// may pass the full, unfiltered allocations here. That only ever +/// over-estimates a column (a hidden wide row can't be under-padded), +/// so alignment stays correct - the filtered view is at worst slightly +/// roomier than strictly necessary. +pub fn computeWidths( + allocations: []const Allocation, + lots: []const Lot, + total_value: f64, + total_gl: f64, + watch_syms: []const []const u8, + watch_prices: ?std.StringHashMap(f64), +) PositionsWidths { + var w: PositionsWidths = .{}; + + for (allocations) |a| { + w.symbol_w = @max(w.symbol_w, fmt.displayCols(a.display_symbol)); + w.shares_w = @max(w.shares_w, sharesCols(a.shares)); + w.price_w = @max(w.price_w, moneyCols(a.avg_cost)); + w.price_w = @max(w.price_w, moneyCols(a.current_price)); + w.value_w = @max(w.value_w, moneyCols(a.market_value)); + w.gainloss_w = @max(w.gainloss_w, gainLossCols(a.unrealized_gain_loss)); + } + + for (lots) |lot| { + if (lot.security_type != .stock) continue; + w.shares_w = @max(w.shares_w, sharesCols(lot.shares)); + w.price_w = @max(w.price_w, moneyCols(lot.open_price)); + const use_price = lot.close_price orelse currentPriceFor(allocations, lot.priceSymbol()); + w.value_w = @max(w.value_w, moneyCols(lot.shares * use_price)); + w.gainloss_w = @max(w.gainloss_w, gainLossCols(lot.shares * (use_price - lot.open_price))); + } + + w.value_w = @max(w.value_w, moneyCols(total_value)); + w.gainloss_w = @max(w.gainloss_w, gainLossCols(total_gl)); + + for (watch_syms) |sym| { + w.symbol_w = @max(w.symbol_w, fmt.displayCols(sym)); + if (watch_prices) |wp| { + if (wp.get(sym)) |price| w.price_w = @max(w.price_w, moneyCols(price)); + } + } + + return w; +} + +/// Display columns of the default Money rendering of `amount` +/// ("$1,234.56"). Money output is ASCII, so byte length == columns. +fn moneyCols(amount: f64) usize { + var buf: [48]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + // 48 bytes covers any realistic dollar amount; the fixed writer + // returns WriteFailed rather than overflowing, in which case we + // fall back to the buffer length (an over-estimate, never an + // under-pad). + Money.from(amount).format(&w) catch return buf.len; + return w.buffered().len; +} + +/// Display columns of the shares rendering ("{d:.1}"). +fn sharesCols(shares: f64) usize { + var buf: [48]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "{d:.1}", .{shares}) catch return buf.len; + return s.len; +} + +/// Display columns of a gain/loss cell: one sign char + the money +/// magnitude. +fn gainLossCols(amount: f64) usize { + return 1 + moneyCols(if (amount < 0) -amount else amount); +} + +/// Current price for `symbol` from the allocations (linear scan; +/// portfolios are small). Returns 0 when absent, which yields a +/// trivially small cell that widens nothing. +fn currentPriceFor(allocations: []const Allocation, symbol: []const u8) f64 { + for (allocations) |a| { + if (std.mem.eql(u8, a.symbol, symbol)) return a.current_price; + } + return 0; +} + +/// Write `content` into a `width`-column field: right-justified (spaces +/// before) when `right`, else left-justified (spaces after). +/// Display-column aware. The streaming analogue of +/// `format.padLeftToCols` / `padRightToCols`, used by the CLI renderer +/// (which prints straight to the output writer) and by the header / +/// separator writers below. +pub fn writeCol(out: *std.Io.Writer, content: []const u8, width: usize, right: bool) std.Io.Writer.Error!void { + const cols = fmt.displayCols(content); + const pad = if (cols >= width) 0 else width - cols; + if (right) { + try out.splatByteAll(' ', pad); + try out.writeAll(content); + } else { + try out.writeAll(content); + try out.splatByteAll(' ', pad); + } +} + +/// Write the CLI holdings-table column header (Symbol .. Account) sized +/// to `w`. Symbol and Date read left-to-right so their labels sit over +/// the start of the data; the numeric columns are right-justified. +pub fn writeHeader(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void { + try out.writeAll(" "); + try writeCol(out, "Symbol", w.symbol_w, false); + try out.writeByte(' '); + try writeCol(out, "Shares", w.shares_w, true); + try out.writeByte(' '); + try writeCol(out, "Avg Cost", w.price_w, true); + try out.writeByte(' '); + try writeCol(out, "Price", w.price_w, true); + try out.writeByte(' '); + try writeCol(out, "Market Value", w.value_w, true); + try out.writeByte(' '); + try writeCol(out, "Gain/Loss", w.gainloss_w, true); + try out.writeByte(' '); + try writeCol(out, "Weight", w.weight_w, true); + try out.writeByte(' '); + try writeCol(out, "Date", w.date_w, false); + try out.writeAll(" Account\n"); +} + +/// Write the CLI dashed separator under the header (9 column rules, +/// matching `writeHeader`). +pub fn writeSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void { + try out.writeAll(" "); + try writeRule(out, w.symbol_w); + try out.writeByte(' '); + try writeRule(out, w.shares_w); + try out.writeByte(' '); + try writeRule(out, w.price_w); + try out.writeByte(' '); + try writeRule(out, w.price_w); + try out.writeByte(' '); + try writeRule(out, w.value_w); + try out.writeByte(' '); + try writeRule(out, w.gainloss_w); + try out.writeByte(' '); + try writeRule(out, w.weight_w); + try out.writeByte(' '); + try writeRule(out, w.date_w); + try out.writeByte(' '); + try writeRule(out, PositionsLayout.account_w); + try out.writeByte('\n'); +} + +/// Write the CLI TOTAL-row sum rule, spanning Symbol .. Weight +/// (7 column rules). +pub fn writeTotalSeparator(out: *std.Io.Writer, w: PositionsWidths) std.Io.Writer.Error!void { + try out.writeAll(" "); + try writeRule(out, w.symbol_w); + try out.writeByte(' '); + try writeRule(out, w.shares_w); + try out.writeByte(' '); + try writeRule(out, w.price_w); + try out.writeByte(' '); + try writeRule(out, w.price_w); + try out.writeByte(' '); + try writeRule(out, w.value_w); + try out.writeByte(' '); + try writeRule(out, w.gainloss_w); + try out.writeByte(' '); + try writeRule(out, w.weight_w); + try out.writeByte('\n'); +} + +/// Write a dashed rule `width` columns wide. +fn writeRule(out: *std.Io.Writer, width: usize) std.Io.Writer.Error!void { + try out.splatByteAll('-', width); +} + // ── Options ─────────────────────────────────────────────────── /// Column layout for the Options section. @@ -420,3 +591,141 @@ test "CDs.init: all matured yields empty active slice" { try testing.expectEqual(@as(usize, 2), cds.expiredItems().len); try testing.expectEqual(@as(usize, 0), cds.activeItems().len); } + +// ── computeWidths ───────────────────────────────────────────── + +/// Build a minimal Allocation for width tests. `cost_basis` is derived +/// so `unrealized_gain_loss` is consistent (not that computeWidths +/// reads cost_basis, but it keeps the fixture honest). +fn mkAlloc(symbol: []const u8, shares: f64, avg_cost: f64, current_price: f64, market_value: f64, gl: f64) Allocation { + return .{ + .symbol = symbol, + .display_symbol = symbol, + .shares = shares, + .avg_cost = avg_cost, + .current_price = current_price, + .market_value = market_value, + .cost_basis = market_value - gl, + .weight = 1.0, + .unrealized_gain_loss = gl, + .unrealized_return = 0, + }; +} + +test "computeWidths: empty portfolio sits at the header-label floors" { + const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null); + try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w); + try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w); + try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w); + try testing.expectEqual(@as(usize, PositionsLayout.min_value_w), w.value_w); + try testing.expectEqual(@as(usize, PositionsLayout.min_gainloss_w), w.gainloss_w); + // Fixed columns are mirrored in unchanged. + try testing.expectEqual(@as(usize, PositionsLayout.weight_w), w.weight_w); + try testing.expectEqual(@as(usize, PositionsLayout.date_w), w.date_w); +} + +test "computeWidths: a small portfolio stays at the floors (tightens, no waste)" { + // IBM(3) / 10.0(4) / $150.00(7) / $155.00(7) / $1,550.00(9) / +$50.00(7) + // are all narrower than their header labels, so every column stays + // at its minimum. This is the "don't waste space" half of + // fit-to-content. + const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)}; + const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null); + try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol" + try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares" + try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost" + try testing.expectEqual(@as(usize, 12), w.value_w); // "Market Value" + try testing.expectEqual(@as(usize, 9), w.gainloss_w); // "Gain/Loss" +} + +test "computeWidths: large crypto-scale values grow each column" { + // DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15) + // / +$5,000,000.00(14, sign + $5,000,000.00). + const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)}; + const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null); + try testing.expectEqual(@as(usize, 8), w.symbol_w); + try testing.expectEqual(@as(usize, 7), w.shares_w); + try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10 + try testing.expectEqual(@as(usize, 15), w.value_w); + try testing.expectEqual(@as(usize, 14), w.gainloss_w); +} + +test "computeWidths: a single lot's gain/loss can exceed the netted position" { + // Position nets to $0 gain/loss, but two opposite-sign lots each + // swing $9,900 ("+$9,900.00" = 10 cols). Without scanning lots the + // Gain/Loss column would size to the $0 net (the 9-col floor) and a + // lot row would overflow. + const allocs = [_]Allocation{mkAlloc("ABC", 200, 100, 100, 20000, 0)}; + const lots = [_]Lot{ + .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0 }, + .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 }, + }; + // Allocations only: stays at the 9-col floor. + const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null); + try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w); + // With lots: grows to fit the per-lot swing. + const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null); + try testing.expectEqual(@as(usize, 10), w.gainloss_w); +} + +test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tolerated" { + const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)}; + const lots = [_]Lot{ + // Non-stock lot is skipped: its huge share count must NOT widen + // the Shares column. + .{ .symbol = "CASHX", .shares = 999999, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash }, + // Closed stock lot: market value / gain-loss use close_price + // (1234), not the allocation's current price (60). gl = + // 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols. + .{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 }, + // Stock lot whose symbol has no matching allocation and no + // close_price: currentPriceFor falls back to 0 (exercises the + // not-found path), contributing nothing. + .{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 }, + }; + const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null); + // Non-stock lot skipped -> Shares stays at the 6-col floor (the + // 999999.0 = 8-col share count was ignored). + try testing.expectEqual(@as(usize, 6), w.shares_w); + // close_price honored -> Gain/Loss grows to the closed lot's 12-col + // swing. (Had currentPriceFor been used instead, the swing would be + // 100*(60-10) = 5000 -> 10 cols, so 12 proves close_price won.) + try testing.expectEqual(@as(usize, 12), w.gainloss_w); +} + +test "computeWidths: watchlist symbols and prices widen Symbol / Price" { + var wp = std.StringHashMap(f64).init(testing.allocator); + defer wp.deinit(); + try wp.put("VERYLONGSYM", 1234.56); // "$1,234.56" = 9 cols + // "NOPRICE" has no map entry, so it widens Symbol but not Price + // (exercises the price-absent branch). + const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" }; + const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp); + try testing.expectEqual(@as(usize, 11), w.symbol_w); + try testing.expectEqual(@as(usize, 9), w.price_w); +} + +test "writeHeader / writeSeparator render the same column count as the widths" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const widths: PositionsWidths = .{ .symbol_w = 8, .shares_w = 7, .price_w = 10, .value_w = 15, .gainloss_w = 14 }; + try writeHeader(&w, widths); + const header = w.buffered(); + try testing.expect(std.mem.indexOf(u8, header, "Symbol") != null); + try testing.expect(std.mem.indexOf(u8, header, "Market Value") != null); + try testing.expect(std.mem.indexOf(u8, header, "Account") != null); + try testing.expect(std.mem.endsWith(u8, header, "\n")); + + var sbuf: [256]u8 = undefined; + var sw: std.Io.Writer = .fixed(&sbuf); + try writeSeparator(&sw, widths); + const sep = sw.buffered(); + // The separator's per-column rules match the header columns. Its one + // intentional difference is the trailing Account rule, which is a + // fixed decorative width (account_w) rather than the natural-width + // "Account" label, so the separator is exactly that much longer. + try testing.expectEqual( + fmt.displayCols(header[0 .. header.len - 1]) - "Account".len + PositionsLayout.account_w, + fmt.displayCols(sep[0 .. sep.len - 1]), + ); +}