const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme_mod = @import("theme.zig"); const tui = @import("../tui.zig"); const App = tui.App; const StyledLine = tui.StyledLine; // ── Data loading ────────────────────────────────────────────── pub fn loadData(app: *App) void { app.analysis_loaded = true; // Ensure portfolio is loaded first if (!app.portfolio_loaded) app.loadPortfolioData(); const pf = app.portfolio orelse return; const summary = app.portfolio_summary orelse return; // Load classification metadata file if (app.classification_map == null) { // Look for metadata.srf next to the portfolio file if (app.portfolio_path) |ppath| { // Derive metadata path: same directory as portfolio, named "metadata.srf" const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; defer app.allocator.free(meta_path); const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch { app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); return; }; defer app.allocator.free(file_data); app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { app.setStatus("Error parsing metadata.srf"); return; }; } } // Load account tax type metadata file (optional) if (app.account_map == null) { if (app.portfolio_path) |ppath| { const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch { loadDataFinish(app, pf, summary); return; }; defer app.allocator.free(acct_path); if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| { defer app.allocator.free(acct_data); app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null; } else |_| { // accounts.srf is optional -- analysis works without it } } } loadDataFinish(app, pf, summary); } fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void { const cm = app.classification_map orelse { app.setStatus("No classification data. Run: zfin enrich > metadata.srf"); return; }; // Free previous result if (app.analysis_result) |*ar| ar.deinit(app.allocator); app.analysis_result = zfin.analysis.analyzePortfolio( app.allocator, summary.allocations, cm, pf, summary.total_value, app.account_map, ) catch { app.setStatus("Error computing analysis"); return; }; } // ── Rendering ───────────────────────────────────────────────── pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { return renderAnalysisLines(arena, app.theme, app.analysis_result); } /// Render analysis tab content. Pure function — no App dependency. pub fn renderAnalysisLines( arena: std.mem.Allocator, th: theme_mod.Theme, analysis_result: ?zfin.analysis.AnalysisResult, ) ![]const StyledLine { var lines: std.ArrayList(StyledLine) = .empty; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const result = analysis_result orelse { try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() }); try lines.append(arena, .{ .text = " Run: zfin enrich > metadata.srf", .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); }; const bar_width: usize = 30; const label_width: usize = 24; const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{ .{ .items = result.asset_class, .title = " Asset Class" }, .{ .items = result.sector, .title = " Sector (Equities)" }, .{ .items = result.geo, .title = " Geographic" }, .{ .items = result.account, .title = " By Account" }, .{ .items = result.tax_type, .title = " By Tax Type" }, }; for (sections, 0..) |sec, si| { if (si > 0 and sec.items.len == 0) continue; if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); for (sec.items) |item| { const text = try fmtBreakdownLine(arena, item, bar_width, label_width); try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } if (result.unclassified.len > 0) { try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() }); for (result.unclassified) |sym| { const text = try std.fmt.allocPrint(arena, " {s}", .{sym}); try lines.append(arena, .{ .text = text, .style = th.mutedStyle() }); } } return lines.toOwnedSlice(arena); } pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 { var val_buf: [24]u8 = undefined; const pct = item.weight * 100.0; const bar = try buildBlockBar(arena, item.weight, bar_width); // Build label padded to label_width const lbl = item.label; const lbl_len = @min(lbl.len, label_width); const padded_label = try arena.alloc(u8, label_width); @memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]); if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' '); return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{ padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value), }); } /// Build a bar using Unicode block elements for sub-character precision. /// Wraps fmt.buildBlockBar into arena-allocated memory. pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 { var buf: [256]u8 = undefined; const result = fmt.buildBlockBar(&buf, weight, total_chars); return arena.dupe(u8, result); } // ── Tests ───────────────────────────────────────────────────────────── const testing = std.testing; test "buildBlockBar empty" { const bar = try buildBlockBar(testing.allocator, 0, 10); defer testing.allocator.free(bar); // All spaces try testing.expectEqual(@as(usize, 10), bar.len); try testing.expectEqualStrings(" ", bar); } test "buildBlockBar full" { const bar = try buildBlockBar(testing.allocator, 1.0, 5); defer testing.allocator.free(bar); // 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88) try testing.expectEqual(@as(usize, 15), bar.len); // Verify first block is █ try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]); } test "buildBlockBar partial" { const bar = try buildBlockBar(testing.allocator, 0.5, 10); defer testing.allocator.free(bar); // 50% of 10 chars = 5 full blocks (no partial) // 5 full blocks (15 bytes) + 5 spaces = 20 bytes try testing.expectEqual(@as(usize, 20), bar.len); } test "fmtBreakdownLine formats correctly" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const item = zfin.analysis.BreakdownItem{ .label = "US Stock", .weight = 0.65, .value = 130000, }; const line = try fmtBreakdownLine(arena, item, 10, 12); // Should contain the label, percentage, and dollar amount try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null); try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null); try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null); } test "renderAnalysisLines with data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme_mod.default_theme; var asset_class = [_]zfin.analysis.BreakdownItem{ .{ .label = "US Stock", .weight = 0.60, .value = 120000 }, .{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 }, }; const result = zfin.analysis.AnalysisResult{ .asset_class = &asset_class, .sector = &.{}, .geo = &.{}, .account = &.{}, .tax_type = &.{}, .unclassified = &.{}, .total_value = 200000, }; const lines = try renderAnalysisLines(arena, th, result); // Should have header section + asset class items try testing.expect(lines.len >= 5); // Find "Portfolio Analysis" header var found_header = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true; } try testing.expect(found_header); // Find asset class data var found_us = false; for (lines) |l| { if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true; } try testing.expect(found_us); } test "renderAnalysisLines no data" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const th = theme_mod.default_theme; const lines = try renderAnalysisLines(arena, th, null); try testing.expectEqual(@as(usize, 5), lines.len); try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null); }