create a view model for options and cds that is reused between cli/tui

This commit is contained in:
Emil Lerch 2026-03-31 17:02:32 -07:00
parent 9de40e8219
commit 1f9f90357f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 368 additions and 172 deletions

View file

@ -2,6 +2,18 @@ const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
const views = @import("../views/portfolio_sections.zig");
/// Map a semantic StyleIntent to CLI ANSI foreground color.
fn setIntentFg(out: *std.Io.Writer, color: bool, intent: fmt.StyleIntent) !void {
if (!color) return;
switch (intent) {
.normal => try cli.reset(out, color),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
}
}
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
// Load portfolio from SRF file
@ -364,36 +376,35 @@ pub fn display(
// Options section
if (portfolio.hasType(.option)) {
var prepared_opts = try views.Options.init(allocator, portfolio.lots, null);
defer prepared_opts.deinit();
if (prepared_opts.items.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Options\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
});
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
"", "", "", "", "",
});
try out.print(views.OptionsLayout.header ++ "\n", views.OptionsLayout.header_labels);
try out.print(views.OptionsLayout.separator ++ "\n", views.OptionsLayout.separator_fills);
try cli.reset(out, color);
var opt_total_cost: f64 = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .option) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const total_cost_opt = @abs(qty) * cost_per;
opt_total_cost += total_cost_opt;
var cost_per_buf: [24]u8 = undefined;
var total_cost_buf: [24]u8 = undefined;
const acct: []const u8 = lot.account orelse "";
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
lot.symbol,
qty,
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
acct,
});
var opt_total_premium: f64 = 0;
for (prepared_opts.items) |po| {
opt_total_premium += po.premium;
const text = po.columns[0].text;
const prem_start = po.premium_col_start;
const prem_end = @min(prem_start + views.OptionsLayout.premium_w, text.len);
// Pre-premium portion
try setIntentFg(out, color, po.row_style);
try out.print("{s}", .{text[0..prem_start]});
// Premium column
try setIntentFg(out, color, po.premium_style);
try out.print("{s}", .{text[prem_start..prem_end]});
// Post-premium portion (account)
try setIntentFg(out, color, po.row_style);
if (prem_end < text.len) try out.print("{s}", .{text[prem_end..]});
try cli.reset(out, color);
try out.print("\n", .{});
}
// Options total
try cli.setFg(out, color, cli.CLR_MUTED);
@ -401,55 +412,31 @@ pub fn display(
try cli.reset(out, color);
var opt_total_buf: [24]u8 = undefined;
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_premium),
});
}
}
// CDs section
if (portfolio.hasType(.cd)) {
var prepared_cds = try views.CDs.init(allocator, portfolio.lots, null);
defer prepared_cds.deinit();
if (prepared_cds.items.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Certificates of Deposit\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
});
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
"", "", "", "", "",
});
try out.print(views.CDsLayout.header ++ "\n", views.CDsLayout.header_labels);
try out.print(views.CDsLayout.separator ++ "\n", views.CDsLayout.separator_fills);
try cli.reset(out, color);
// Collect and sort CDs by maturity date (earliest first)
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.security_type == .cd) {
try cd_lots.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
var cd_section_total: f64 = 0;
for (cd_lots.items) |lot| {
cd_section_total += lot.shares;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
lot.symbol,
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
});
for (prepared_cds.items) |pc| {
cd_section_total += pc.lot.shares;
try setIntentFg(out, color, pc.row_style);
try out.print("{s}\n", .{pc.text});
try cli.reset(out, color);
}
// CD total
try cli.setFg(out, color, cli.CLR_MUTED);
@ -460,6 +447,7 @@ pub fn display(
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
});
}
}
// Cash section
if (portfolio.hasType(.cash)) {

View file

@ -394,6 +394,17 @@ pub fn lotMaturityThenSymbolSortFn(_: void, a: Lot, b: Lot) bool {
return std.mem.lessThan(u8, a.symbol, b.symbol);
}
// Shared style intent
/// Semantic style intent renderers map this to platform-specific styles.
/// Used by view models (e.g. views/portfolio_sections.zig) and renderers.
pub const StyleIntent = enum {
normal, // default text
muted, // dim/secondary (expired items)
positive, // green (gains, premium received)
negative, // red (losses, premium paid)
};
/// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket.
pub const DripSummary = struct {
lot_count: usize = 0,

View file

@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const views = @import("views/portfolio_sections.zig");
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig");
@ -181,6 +182,13 @@ pub const PortfolioRow = struct {
drip_avg_cost: f64 = 0,
drip_date_first: ?zfin.Date = null,
drip_date_last: ?zfin.Date = null,
/// Pre-formatted text from view model (options and CDs)
prepared_text: ?[]const u8 = null,
/// Semantic styles from view model
row_style: fmt.StyleIntent = .normal,
premium_style: fmt.StyleIntent = .normal,
/// Column offset for premium alt-style coloring (options only)
premium_col_start: usize = 0,
const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, illiquid_row, illiquid_total, drip_summary };
};
@ -289,6 +297,8 @@ pub const App = struct {
cash_expanded: bool = false, // whether cash section is expanded to show per-account
illiquid_expanded: bool = false, // whether illiquid section is expanded to show per-asset
portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
prepared_options: ?views.Options = null,
prepared_cds: ?views.CDs = null,
portfolio_header_lines: usize = 0, // number of styled lines before data rows
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index
portfolio_line_count: usize = 0, // total styled lines in portfolio view
@ -1318,6 +1328,13 @@ pub const App = struct {
self.portfolio_summary = null;
}
pub fn freePreparedSections(self: *App) void {
if (self.prepared_options) |*opts| opts.deinit();
self.prepared_options = null;
if (self.prepared_cds) |*cds| cds.deinit();
self.prepared_cds = null;
}
fn deinitData(self: *App) void {
self.freeCandles();
self.freeDividends();
@ -1325,6 +1342,7 @@ pub const App = struct {
self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary();
self.freePreparedSections();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);

View file

@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const views = @import("../views/portfolio_sections.zig");
const cli = @import("../commands/common.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
@ -32,6 +33,16 @@ pub const col_end_date: usize = col_end_weight + 14;
// Gain/loss column start position (used for alt-style coloring)
const gl_col_start: usize = col_end_market_value;
/// Map a semantic StyleIntent to a platform-specific vaxis style.
fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
return switch (intent) {
.normal => th.contentStyle(),
.muted => th.mutedStyle(),
.positive => th.positiveStyle(),
.negative => th.negativeStyle(),
};
}
// Data loading
/// Load portfolio data: prices, summary, candle map, and historical snapshots.
@ -268,6 +279,7 @@ pub fn sortPortfolioAllocations(app: *App) void {
pub fn rebuildPortfolioRows(app: *App) void {
app.portfolio_rows.clearRetainingCapacity();
app.freePreparedSections();
if (app.portfolio_summary) |s| {
for (s.allocations, 0..) |a, i| {
@ -415,50 +427,42 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Options section (sorted by expiration date, then symbol; filtered by account)
if (app.portfolio) |pf| {
if (pf.hasType(.option)) {
var option_lots: std.ArrayList(zfin.Lot) = .empty;
defer option_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) {
option_lots.append(app.allocator, lot) catch continue;
}
}
if (option_lots.items.len > 0) {
app.prepared_options = views.Options.init(app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_options) |opts| {
if (opts.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
std.mem.sort(zfin.Lot, option_lots.items, {}, fmt.lotMaturityThenSymbolSortFn);
for (option_lots.items) |lot| {
for (opts.items) |po| {
app.portfolio_rows.append(app.allocator, .{
.kind = .option_row,
.symbol = lot.symbol,
.lot = lot,
.symbol = po.lot.symbol,
.lot = po.lot,
.prepared_text = po.columns[0].text,
.row_style = po.row_style,
.premium_style = po.premium_style,
.premium_col_start = po.premium_col_start,
}) catch continue;
}
}
}
// CDs section (sorted by maturity date, earliest first; filtered by account)
if (pf.hasType(.cd)) {
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cd and matchesAccountFilter(app, lot.account)) {
cd_lots.append(app.allocator, lot) catch continue;
}
}
if (cd_lots.items.len > 0) {
app.prepared_cds = views.CDs.init(app.allocator, pf.lots, app.account_filter) catch null;
if (app.prepared_cds) |cds| {
if (cds.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
for (cd_lots.items) |lot| {
for (cds.items) |pc| {
app.portfolio_rows.append(app.allocator, .{
.kind = .cd_row,
.symbol = lot.symbol,
.lot = lot,
.symbol = pc.lot.symbol,
.lot = pc.lot,
.prepared_text = pc.text,
.row_style = pc.row_style,
}) catch continue;
}
}
@ -1016,82 +1020,29 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
// Add column headers for each section type
if (std.mem.eql(u8, row.symbol, "Options")) {
const col_hdr = try std.fmt.allocPrint(arena, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{
"Contract", "Qty", "Cost/Ctrct", "Premium", "Account",
});
const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
const col_hdr = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account",
});
const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
}
},
.option_row => {
if (row.lot) |lot| {
// Options: symbol (description), qty (contracts), cost/contract, premium (+/-), account
const qty = lot.shares; // negative = short
const cost_per = lot.open_price; // per-contract cost
const total_premium = @abs(qty) * cost_per * lot.multiplier;
// Short = received premium (+), Long = paid premium (-)
const received = qty < 0;
var cost_buf3: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, total_premium);
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct_col2: []const u8 = lot.account orelse "";
// Column layout: 4 + 30 + 1 + 6 + 1 + 12 + 1 = 55 (premium start)
const prem_col_start: usize = 55;
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
lot.symbol,
qty,
fmt.fmtMoneyAbs(&cost_buf3, cost_per),
prem_str,
acct_col2,
});
const today = fmt.todayDate();
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
const row_style2 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle();
const prem_style = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else if (received) th.positiveStyle() else th.negativeStyle();
if (row.prepared_text) |text| {
const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
try lines.append(arena, .{
.text = text,
.style = row_style2,
.alt_style = prem_style,
.alt_start = prem_col_start,
.alt_end = prem_col_start + 14,
.alt_start = row.premium_col_start,
.alt_end = row.premium_col_start + 14,
});
}
},
.cd_row => {
if (row.lot) |lot| {
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_str_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
// Truncate note to 40 chars for display
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct_col3: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
lot.symbol,
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
acct_col3,
});
const today = fmt.todayDate();
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
const row_style3 = if (is_cursor) th.selectStyle() else if (is_expired) th.mutedStyle() else th.contentStyle();
if (row.prepared_text) |text| {
const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
try lines.append(arena, .{ .text = text, .style = row_style3 });
}
},

View file

@ -0,0 +1,228 @@
//! View models for portfolio sections (Options, CDs).
//! Produces 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.
const std = @import("std");
const Lot = @import("../models/portfolio.zig").Lot;
const Date = @import("../models/date.zig").Date;
const fmt = @import("../format.zig");
// Options
/// Column layout for the Options section.
/// All format strings are derived from the width constants.
pub const OptionsLayout = struct {
const cp = std.fmt.comptimePrint;
pub const prefix = " ";
pub const symbol_w = 30;
pub const qty_w = 6;
pub const cost_w = 12;
pub const premium_w = 14;
pub const account_w = 10;
pub const premium_col_start: usize = prefix.len + symbol_w + 1 + qty_w + 1 + cost_w + 1;
pub const header = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:>{d}}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
pub const header_labels = .{ "Contract", "Qty", "Cost/Ctrct", "Premium", "Account" };
pub const separator = prefix ++ cp("{{s:->{d}}}", .{symbol_w}) ++ " " ++ cp("{{s:->{d}}}", .{qty_w}) ++ " " ++ cp("{{s:->{d}}}", .{cost_w}) ++ " " ++ cp("{{s:->{d}}}", .{premium_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
pub const separator_fills = .{ "", "", "", "", "" };
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{symbol_w}) ++ " " ++ cp("{{d:>{d}.0}}", .{qty_w}) ++ " " ++ cp("{{s:>{d}}}", .{cost_w}) ++ " " ++ cp("{{s:>{d}}}", .{premium_w}) ++ " {s}";
};
/// A styled text span for multi-style row rendering.
pub const StyledSpan = struct {
text: []const u8,
style: fmt.StyleIntent,
};
/// A single option row with pre-computed display values.
pub const Option = struct {
lot: Lot,
premium: f64,
received: bool,
is_expired: bool,
row_style: fmt.StyleIntent,
premium_style: fmt.StyleIntent,
columns: [2]StyledSpan,
premium_col_start: usize,
};
/// Collection of prepared option rows. Owns all allocated text.
pub const Options = struct {
items: []const Option,
allocator: std.mem.Allocator,
/// Build sorted, filtered, display-ready option rows from raw lots.
pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !Options {
const today = fmt.todayDate();
var list: std.ArrayList(Option) = .empty;
errdefer {
for (list.items) |opt| allocator.free(opt.columns[0].text);
list.deinit(allocator);
}
var tmp: std.ArrayList(Lot) = .empty;
defer tmp.deinit(allocator);
for (lots) |lot| {
if (lot.security_type != .option) continue;
if (account_filter) |af| {
const la = lot.account orelse "";
if (!std.mem.eql(u8, la, af)) continue;
}
try tmp.append(allocator, lot);
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
for (tmp.items) |lot| {
const qty = lot.shares;
const cost_per = lot.open_price;
const premium = @abs(qty) * cost_per * lot.multiplier;
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
const received = qty < 0;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
var cost_buf: [24]u8 = undefined;
var prem_val_buf: [24]u8 = undefined;
const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, premium);
var prem_buf: [20]u8 = undefined;
const prem_str = if (received)
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
else
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
lot.symbol,
qty,
fmt.fmtMoneyAbs(&cost_buf, cost_per),
prem_str,
acct,
});
try list.append(allocator, .{
.lot = lot,
.premium = premium,
.received = received,
.is_expired = is_expired,
.row_style = row_style,
.premium_style = premium_style,
.columns = .{
.{ .text = text, .style = row_style },
.{ .text = prem_str, .style = premium_style },
},
.premium_col_start = OptionsLayout.premium_col_start,
});
}
return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator };
}
pub fn deinit(self: *Options) void {
for (self.items) |opt| self.allocator.free(opt.columns[0].text);
self.allocator.free(self.items);
self.items = &.{};
}
};
// CDs
/// Column layout for the Certificates of Deposit section.
pub const CDsLayout = struct {
const cp = std.fmt.comptimePrint;
pub const prefix = " ";
pub const cusip_w = 12;
pub const face_w = 14;
pub const rate_w = 7;
pub const maturity_w = 10;
pub const desc_w = 40;
pub const account_w = 10;
pub const header = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
pub const header_labels = .{ "CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account" };
pub const separator = prefix ++ cp("{{s:->{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:->{d}}}", .{face_w}) ++ " " ++ cp("{{s:->{d}}}", .{rate_w}) ++ " " ++ cp("{{s:->{d}}}", .{maturity_w}) ++ " " ++ cp("{{s:->{d}}}", .{desc_w}) ++ " " ++ cp("{{s:->{d}}}", .{account_w});
pub const separator_fills = .{ "", "", "", "", "", "" };
pub const data_row = prefix ++ cp("{{s:<{d}}}", .{cusip_w}) ++ " " ++ cp("{{s:>{d}}}", .{face_w}) ++ " " ++ cp("{{s:>{d}}}", .{rate_w}) ++ " " ++ cp("{{s:>{d}}}", .{maturity_w}) ++ " {s} {s}";
};
/// A single CD row with pre-computed display values.
pub const CD = struct {
lot: Lot,
is_expired: bool,
row_style: fmt.StyleIntent,
text: []const u8,
};
/// Collection of prepared CD rows. Owns all allocated text.
pub const CDs = struct {
items: []const CD,
allocator: std.mem.Allocator,
/// Build sorted, filtered, display-ready CD rows from raw lots.
pub fn init(allocator: std.mem.Allocator, lots: []const Lot, account_filter: ?[]const u8) !CDs {
const today = fmt.todayDate();
var list: std.ArrayList(CD) = .empty;
errdefer {
for (list.items) |cd| allocator.free(cd.text);
list.deinit(allocator);
}
var tmp: std.ArrayList(Lot) = .empty;
defer tmp.deinit(allocator);
for (lots) |lot| {
if (lot.security_type != .cd) continue;
if (account_filter) |af| {
const la = lot.account orelse "";
if (!std.mem.eql(u8, la, af)) continue;
}
try tmp.append(allocator, lot);
}
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
for (tmp.items) |lot| {
const is_expired = if (lot.maturity_date) |md| md.lessThan(today) else false;
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct = lot.account orelse "";
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
lot.symbol,
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
acct,
});
try list.append(allocator, .{
.lot = lot,
.is_expired = is_expired,
.row_style = row_style,
.text = text,
});
}
return .{ .items = try list.toOwnedSlice(allocator), .allocator = allocator };
}
pub fn deinit(self: *CDs) void {
for (self.items) |cd| self.allocator.free(cd.text);
self.allocator.free(self.items);
self.items = &.{};
}
};