add symbol overlay to see what symbols are

This commit is contained in:
Emil Lerch 2026-06-10 12:50:20 -07:00
parent cd6e22f5ba
commit 41027c4efd
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 457 additions and 86 deletions

View file

@ -84,6 +84,7 @@ const HistoricalPeriod = zfin.valuation.HistoricalPeriod;
const Candle = zfin.Candle;
const Dividend = zfin.Dividend;
const AccountMap = zfin.analysis.AccountMap;
const ClassificationMap = zfin.classification.ClassificationMap;
const DataService = zfin.DataService;
// Public types
@ -206,6 +207,7 @@ pub const WorkerDelays = struct {
candles_ms: usize = 0,
dividends_ms: usize = 0,
account_map_ms: usize = 0,
classification_map_ms: usize = 0,
};
// State
@ -286,6 +288,9 @@ dividends_data: ?std.StringHashMap([]const Dividend) = null,
account_map_future: ?std.Io.Future(void) = null,
account_map_data: ?AccountMap = null,
classification_map_future: ?std.Io.Future(void) = null,
classification_map_data: ?ClassificationMap = null,
// Construction
pub fn init(opts: InitOptions) PortfolioData {
@ -383,6 +388,35 @@ pub fn invalidateAccountMap(self: *PortfolioData) void {
self.account_map_future = self.io.async(accountMapWorker, .{ self, @as(usize, 0) });
}
/// Per-symbol classification metadata loaded from `metadata.srf`.
/// Blocks on the classification-map worker. Returns null when
/// there's no portfolio loaded or `metadata.srf` doesn't exist /
/// can't be parsed.
///
/// Used by analysis_tab, review_tab, and the App-level symbol
/// overlay. Sharing a single PortfolioData-scoped copy avoids
/// the duplicate-load problem the per-tab caches had.
pub fn classificationMap(self: *PortfolioData) ?*const ClassificationMap {
self.awaitWorker(&self.classification_map_future);
if (self.classification_map_data) |*cm| return cm;
return null;
}
/// Drop the cached classification_map and re-spawn its worker so
/// the next `classificationMap()` call re-reads `metadata.srf`
/// from disk. Re-spawn uses delay 0 (refresh is user-initiated).
///
/// The classification_map's storage lives in the per-load arena;
/// nulling the field is sufficient the next `arena.reset()`
/// reaps the bytes. We don't call `cm.deinit()` here because
/// that would touch the arena and double-free at reset time.
pub fn invalidateClassificationMap(self: *PortfolioData) void {
if (self.classification_map_future) |*f| _ = f.cancel(self.io);
self.classification_map_future = null;
self.classification_map_data = null;
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
}
/// Drain a worker future. Idempotent (Future.await is itself
/// idempotent); safe to call every time.
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
@ -428,6 +462,7 @@ pub fn load(
self.snapshots_data = null;
self.dividends_data = null;
self.account_map_data = null;
self.classification_map_data = null;
// candles_data lives in candles_arena and survives across
// reloads kept entries are reused, only new symbols hit
@ -600,6 +635,7 @@ pub fn load(
self.snapshots_future = self.io.async(snapshotsWorker, .{ self, today, positions_arena, opts.delays.snapshots_ms });
self.dividends_future = self.io.async(dividendsWorker, .{ self, opts.delays.dividends_ms });
self.account_map_future = self.io.async(accountMapWorker, .{ self, opts.delays.account_map_ms });
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, opts.delays.classification_map_ms });
return .{
.cached_count = load_all.cached_count,
@ -661,6 +697,9 @@ pub fn cancelLoad(self: *PortfolioData) void {
if (self.account_map_future) |*f| _ = f.cancel(self.io);
self.account_map_future = null;
self.account_map_data = null;
if (self.classification_map_future) |*f| _ = f.cancel(self.io);
self.classification_map_future = null;
self.classification_map_data = null;
}
// Internal: workers
@ -741,6 +780,21 @@ fn accountMapWorker(self: *PortfolioData, delay_ms: usize) void {
self.account_map_data = self.svc.loadAccountMap(self.allocator(), ppath);
}
fn classificationMapWorker(self: *PortfolioData, delay_ms: usize) void {
self.io.sleep(.fromMilliseconds(@intCast(delay_ms)), .real) catch return;
const ppath = self.anchorPath() orelse return;
// Derive metadata.srf path: same directory as the portfolio
// anchor. Mirrors the per-tab loaders we're consolidating.
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(self.arena.child_allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer self.arena.child_allocator.free(meta_path);
const file_data = std.Io.Dir.cwd().readFileAlloc(self.io, meta_path, self.arena.child_allocator, .limited(1024 * 1024)) catch return;
defer self.arena.child_allocator.free(file_data);
self.classification_map_data = zfin.classification.parseClassificationFile(self.allocator(), file_data) catch null;
}
fn dupePositions(arena: Allocator, src: []const zfin.Position) []const zfin.Position {
const dup = arena.alloc(zfin.Position, src.len) catch return &.{};
@memcpy(dup, src);
@ -830,6 +884,8 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
try testing.expect(pd.snapshots_data == null);
try testing.expect(pd.account_map_future == null);
try testing.expect(pd.account_map_data == null);
try testing.expect(pd.classification_map_future == null);
try testing.expect(pd.classification_map_data == null);
}
test "PortfolioData.candles: returns null after cancelLoad with no data" {
@ -849,6 +905,7 @@ test "PortfolioData.candles: returns null after cancelLoad with no data" {
try testing.expect(pd.dividends() == null);
try testing.expect(pd.snapshots() == null);
try testing.expect(pd.accountMap() == null);
try testing.expect(pd.classificationMap() == null);
}
test "PortfolioData.load: NoPaths error on empty paths slice" {
@ -979,6 +1036,7 @@ test "PortfolioData.WorkerDelays: defaults are all zero" {
try testing.expectEqual(@as(usize, 0), d.candles_ms);
try testing.expectEqual(@as(usize, 0), d.dividends_ms);
try testing.expectEqual(@as(usize, 0), d.account_map_ms);
try testing.expectEqual(@as(usize, 0), d.classification_map_ms);
}
// Persistent candle cache tests

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 Money = @import("Money.zig");
const cli = @import("commands/common.zig");
const portfolio_loader = @import("portfolio_loader.zig");
const stderr = @import("stderr.zig");
@ -463,6 +464,13 @@ pub const App = struct {
// is unobservable.
symbol_buf: [16]u8 = undefined,
symbol_owned: bool = false,
/// Symbol the overlay popup is currently showing details for.
/// Empty when the overlay is closed. Bytes live in
/// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`.
overlay_symbol: []const u8 = "",
// SAFETY: paired with `overlay_symbol`; only the prefix
// overlay_symbol_buf[0..overlay_symbol.len] is observable.
overlay_symbol_buf: [16]u8 = undefined,
scroll_offset: usize = 0,
visible_height: u16 = 24, // updated each draw
/// Monotonic counter incremented each draw frame. Passed to the
@ -1150,6 +1158,28 @@ pub const App = struct {
self.resetSymbolData();
}
/// Open / close / re-target the symbol-info overlay. Behavior:
/// - overlay closed open with `sym`.
/// - overlay open, same `sym` as cursor close.
/// - overlay open, different `sym` re-target to `sym`.
///
/// `sym` is empty (no cursor on a symbol-bearing row) close
/// the overlay if open, no-op if closed.
pub fn toggleOverlay(self: *App, sym: []const u8) void {
if (sym.len == 0) {
self.overlay_symbol = "";
return;
}
if (self.overlay_symbol.len > 0 and std.ascii.eqlIgnoreCase(self.overlay_symbol, sym)) {
self.overlay_symbol = "";
return;
}
const len = @min(sym.len, self.overlay_symbol_buf.len);
@memcpy(self.overlay_symbol_buf[0..len], sym[0..len]);
for (self.overlay_symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
self.overlay_symbol = self.overlay_symbol_buf[0..len];
}
fn resetSymbolData(self: *App) void {
// Tab-private symbol-bound state is dropped via each tab's
// onSymbolChange hook (where defined). Distinct from
@ -1367,6 +1397,29 @@ pub const App = struct {
const status_surface = try self.drawStatusBar(ctx, max_size.width);
try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface });
// Symbol overlay (`K` key). Anchored to the bottom-right
// of the content area with z_index=1 so the vaxis
// compositor renders it on top of the tab content. Width
// and height auto-fit; we clamp the origin so it always
// stays inside the viewport even on narrow terminals.
if (self.overlay_symbol.len > 0) {
if (try self.drawSymbolOverlay(ctx, max_size.width, content_height)) |ov| {
const row: u16 = if (ov.size.height + 1 < max_size.height - 1)
@intCast(max_size.height - 1 - ov.size.height)
else
1;
const col: u16 = if (ov.size.width < max_size.width)
@intCast(max_size.width - ov.size.width)
else
0;
try children.append(ctx.arena, .{
.origin = .{ .row = row, .col = col },
.surface = ov,
.z_index = 1,
});
}
}
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) };
}
@ -1552,6 +1605,192 @@ pub const App = struct {
}
}
/// Build the symbol-info overlay surface. Returns null when
/// nothing useful can be rendered (no portfolio loaded).
/// Anchored bottom-right by the caller via SubSurface origin.
///
/// Width is fixed at a comfortable column count (or whatever
/// fits if the terminal is narrower); height auto-sizes to
/// the line count. The compositor draws it on top of the
/// content area via z_index=1.
fn drawSymbolOverlay(
self: *App,
ctx: vaxis.vxfw.DrawContext,
max_width: u16,
max_height: u16,
) !?vaxis.vxfw.Surface {
const lines = try self.buildOverlayLines(ctx.arena);
if (lines.len == 0) return null;
// Width: longest line + 4 for borders + a comfortable cap
// so the box doesn't dominate wide terminals.
const overlay_width_cap: usize = 60;
var content_w: usize = 0;
for (lines) |ln| {
// Approximate display width by byte length. ASCII-only
// for the v1 content; emoji/multibyte would need a
// fancier measurement.
if (ln.text.len > content_w) content_w = ln.text.len;
}
content_w = @min(content_w, overlay_width_cap);
const total_w: u16 = @intCast(@min(content_w + 4, max_width));
const total_h: u16 = @intCast(@min(lines.len + 2, max_height));
if (total_w < 4 or total_h < 3) return null;
const buf = try ctx.arena.alloc(vaxis.Cell, @as(usize, total_w) * @as(usize, total_h));
const th = self.theme;
const box_style = th.contentStyle();
// Fill with spaces in box_style (overwrites whatever was
// beneath; opaque overlay).
for (buf) |*c| c.* = .{ .char = .{ .grapheme = " " }, .style = box_style };
// Border. Top + bottom rows + first/last column.
const tl = "";
const tr = "";
const bl = "";
const br = "";
const h_border = "";
const v_border = "";
// Top
buf[0] = .{ .char = .{ .grapheme = tl }, .style = box_style };
buf[total_w - 1] = .{ .char = .{ .grapheme = tr }, .style = box_style };
for (1..total_w - 1) |c| buf[c] = .{ .char = .{ .grapheme = h_border }, .style = box_style };
// Bottom
const last_row = (@as(usize, total_h) - 1) * @as(usize, total_w);
buf[last_row] = .{ .char = .{ .grapheme = bl }, .style = box_style };
buf[last_row + total_w - 1] = .{ .char = .{ .grapheme = br }, .style = box_style };
for (1..total_w - 1) |c| buf[last_row + c] = .{ .char = .{ .grapheme = h_border }, .style = box_style };
// Left + right verticals
for (1..total_h - 1) |r| {
buf[r * total_w] = .{ .char = .{ .grapheme = v_border }, .style = box_style };
buf[r * total_w + total_w - 1] = .{ .char = .{ .grapheme = v_border }, .style = box_style };
}
// Content rows: render each StyledLine starting at col 2,
// row 1+i, capped at content_w bytes.
const inner_w: usize = @as(usize, total_w) - 4;
for (lines, 0..) |line, i| {
const row = 1 + i;
if (row >= total_h - 1) break;
const row_off = row * @as(usize, total_w) + 2;
// Reset interior cells to the line's style.
for (0..inner_w) |c| {
buf[row_off + c] = .{ .char = .{ .grapheme = " " }, .style = line.style };
}
// Write text bytes (ASCII assumption; if a name has
// multibyte characters we'd need fancier handling but
// in practice security names are ASCII).
const limit = @min(line.text.len, inner_w);
for (0..limit) |c| {
buf[row_off + c] = .{
.char = .{ .grapheme = glyph(line.text[c]) },
.style = line.style,
};
}
}
return .{
.size = .{ .width = total_w, .height = total_h },
.widget = self.widget(),
.buffer = buf,
.children = &.{},
};
}
/// Build the overlay's content lines for the currently-active
/// `overlay_symbol`. Pulls name + classification from
/// `pd.classificationMap()`; pulls allocation data (shares,
/// price, market value, weight) from `pd.summary`.
fn buildOverlayLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
const th = self.theme;
const sym = self.overlay_symbol;
// Header: symbol (and name if available).
const cm_opt = self.portfolio.classificationMap();
const ce_opt: ?*const zfin.classification.ClassificationEntry = blk: {
if (cm_opt) |cm| {
for (cm.entries) |*e| {
if (std.mem.eql(u8, e.symbol, sym)) break :blk e;
}
}
break :blk null;
};
const display_name: []const u8 = if (ce_opt) |ce| (ce.name orelse sym) else sym;
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
.style = th.headerStyle(),
});
if (display_name.ptr != sym.ptr and display_name.len > 0) {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{display_name}),
.style = th.contentStyle(),
});
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Classification rows.
if (ce_opt) |ce| {
if (ce.asset_class) |ac| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Asset class: {s}", .{ac}),
.style = th.contentStyle(),
});
}
if (ce.sector) |s| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Sector: {s}", .{s}),
.style = th.contentStyle(),
});
}
if (ce.geo) |g| {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Geo: {s}", .{g}),
.style = th.contentStyle(),
});
}
} else {
try lines.append(arena, .{
.text = " (no metadata.srf entry)",
.style = th.mutedStyle(),
});
}
// Allocation row from pd.summary.
if (self.portfolio.summary) |summary| {
for (summary.allocations) |a| {
if (!std.mem.eql(u8, a.symbol, sym)) continue;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Shares: {d:.4}", .{a.shares}),
.style = th.contentStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(a.current_price)}),
.style = th.contentStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Value: {f}", .{Money.from(a.market_value)}),
.style = th.contentStyle(),
});
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Weight: {d:.2}%", .{a.weight * 100.0}),
.style = th.contentStyle(),
});
break;
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = " K to close",
.style = th.mutedStyle(),
});
return lines.toOwnedSlice(arena);
}
fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
const t = self.theme;
const buf = try ctx.arena.alloc(vaxis.Cell, width);
@ -2461,3 +2700,66 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $
try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null);
try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null);
}
// overlay state transitions
//
// The `toggleOverlay` semantics:
// - empty `sym` argument close overlay (no-op when closed).
// - overlay closed, non-empty open with sym.
// - overlay open, same sym close.
// - overlay open, different sym re-target to sym.
//
// We test it by manipulating App fields directly. App.toggleOverlay
// only reads/writes `overlay_symbol` and `overlay_symbol_buf`, so
// we don't need a fully-wired App.
test "toggleOverlay: opens when closed and sym is non-empty" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("VTI");
try testing.expectEqualStrings("VTI", app.overlay_symbol);
}
test "toggleOverlay: closes when same sym is passed twice" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("VTI");
app.toggleOverlay("VTI");
try testing.expectEqualStrings("", app.overlay_symbol);
}
test "toggleOverlay: re-targets when sym differs" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("VTI");
app.toggleOverlay("BND");
try testing.expectEqualStrings("BND", app.overlay_symbol);
}
test "toggleOverlay: empty sym closes overlay if open, no-op when closed" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("");
try testing.expectEqualStrings("", app.overlay_symbol);
app.toggleOverlay("VTI");
app.toggleOverlay("");
try testing.expectEqualStrings("", app.overlay_symbol);
}
test "toggleOverlay: uppercases the stored symbol" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("vti");
try testing.expectEqualStrings("VTI", app.overlay_symbol);
}
test "toggleOverlay: same-sym match is case-insensitive" {
var app: App = undefined;
app.overlay_symbol = "";
app.toggleOverlay("VTI");
// Lowercase "vti" should match the stored "VTI" and close,
// not re-target (which would be a needless redraw).
app.toggleOverlay("vti");
try testing.expectEqualStrings("", app.overlay_symbol);
}

View file

@ -28,11 +28,6 @@ pub const State = struct {
/// Computed analysis output. Owned by State; freed in
/// `deinit` and `reload`.
result: ?zfin.analysis.AnalysisResult = null,
/// Per-portfolio classification metadata (`metadata.srf`).
/// Used only by analysis today; lives here because no other
/// tab consumes it. Loaded lazily on first activation; freed
/// in `deinit`.
classification_map: ?zfin.classification.ClassificationMap = null,
/// Sector display granularity. Cycled via `cycle_sector_granularity`
/// action. Default `mid` matches the CLI default
/// (`zfin analysis` without `--sector-detail`).
@ -65,7 +60,6 @@ pub const tab = struct {
pub fn deinit(state: *State, app: *App) void {
if (state.result) |*ar| ar.deinit(app.allocator);
if (state.classification_map) |*cm| cm.deinit();
state.* = .{};
}
@ -78,19 +72,16 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State);
/// Force re-fetch on user request. Frees the analysis result
/// AND the shared `account_map` on App (analysis's refresh
/// also re-reads accounts.srf). The classification_map persists
/// it's per-portfolio, not per-symbol or per-refresh.
/// and invalidates the shared `account_map` + `classification_map`
/// on PortfolioData so the next load re-reads `accounts.srf`
/// and `metadata.srf` from disk (the user may have edited
/// either).
pub fn reload(state: *State, app: *App) !void {
if (state.result) |*ar| ar.deinit(app.allocator);
state.result = null;
state.loaded = false;
// Refresh-analysis intentionally drops the shared account
// map so the next load re-reads `accounts.srf` from disk
// (the user may have edited it). PortfolioData re-spawns
// the account_map worker with delay=0 so the next
// accountMap() call resolves quickly.
app.portfolio.invalidateAccountMap();
app.portfolio.invalidateClassificationMap();
loadData(state, app);
}
@ -126,18 +117,18 @@ pub const tab = struct {
/// memory (allocations/symbols), so we have to invalidate
/// it before the underlying data is freed.
///
/// Also drops `classification_map` because the user may
/// have re-enriched alongside their portfolio edits.
/// `account_map` lives on App (shared with portfolio_tab)
/// and is reset by PortfolioData itself.
/// `classification_map` and `account_map` live on
/// PortfolioData and are reset by pd's own load path
/// nothing to free here for those.
///
/// Eagerly recomputes only if this tab is currently active
/// otherwise next `activate` will lazy-load.
pub fn onPortfolioReload(state: *State, app: *App) void {
if (state.result) |*ar| ar.deinit(app.allocator);
state.result = null;
if (state.classification_map) |*cm| cm.deinit();
state.classification_map = null;
// classification_map lives on PortfolioData now; the
// reload's broadcast already invalidated it via pd's own
// reset path. Nothing to free here.
state.loaded = false;
if (app.active_tab == .analysis) {
tab.activate(state, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err});
@ -156,33 +147,14 @@ fn loadData(state: *State, app: *App) void {
const pf = app.portfolio.file orelse return;
const summary_ptr = if (app.portfolio.summary) |*s| s else return;
// Load classification metadata file
if (state.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (app.anchorPath()) |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.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch {
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
defer app.allocator.free(file_data);
state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
app.setStatus("Error parsing metadata.srf");
return;
};
}
}
loadDataFinish(state, app, pf, summary_ptr.*);
}
fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = state.classification_map orelse {
// classificationMap() blocks on the classification-map
// worker; first call may briefly wait while metadata.srf
// parses. Returns null when metadata.srf is missing.
const cm_ptr = app.portfolio.classificationMap() orelse {
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
@ -197,7 +169,7 @@ fn loadDataFinish(state: *State, app: *App, pf: zfin.Portfolio, summary: zfin.va
state.result = zfin.analysis.analyzePortfolio(
app.allocator,
summary.allocations,
cm,
cm_ptr.*,
pf,
summary.total_value,
acct_map_opt,
@ -220,7 +192,8 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
total_value = summary.total_value;
if (app.portfolio.file) |pf| {
const benchmark = @import("../analytics/benchmark.zig");
const cm_entries = if (state.classification_map) |cm| cm.entries else &.{};
const cm_entries: []const zfin.classification.ClassificationEntry =
if (app.portfolio.classificationMap()) |cm| cm.entries else &.{};
const split = benchmark.deriveAllocationSplit(
summary.allocations,
cm_entries,
@ -529,7 +502,7 @@ test "tab.init produces zero-defaulted state" {
try tab.init(&state, &dummy_app);
try testing.expectEqual(false, state.loaded);
try testing.expect(state.result == null);
try testing.expect(state.classification_map == null);
// classification_map lives on PortfolioData now (not on tab state).
// Default sector granularity is mid (matches CLI default).
try testing.expectEqual(zfin.analysis.Granularity.mid, state.sector_granularity);
}

View file

@ -135,6 +135,11 @@ pub const Action = enum {
/// Select the cursor row's symbol as the currently-active
/// symbol for the per-symbol tabs (quote/perf/options/etc.).
select_symbol,
/// Toggle the symbol-info overlay popup. Open at cursor
/// row's symbol when closed; close when open and cursor is
/// on the same symbol; re-target when open and cursor is on
/// a different symbol.
toggle_overlay,
};
// Tab-private state
@ -260,6 +265,10 @@ pub const meta: framework.TabMeta(Action) = .{
.{ .action = .clear_account_filter, .key = .{ .codepoint = vaxis.Key.escape } },
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
// Capital K: Vim-style "what is this" toggle. Opens
// (or re-targets) the symbol-info overlay for the
// cursor row's symbol.
.{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.expand_collapse = "Expand/collapse position",
@ -269,12 +278,14 @@ pub const meta: framework.TabMeta(Action) = .{
.open_account_picker = "Filter by account",
.clear_account_filter = "Clear account filter",
.select_symbol = "Select symbol",
.toggle_overlay = "Show symbol details",
}),
.status_hints = &.{
.sort_col_prev,
.sort_col_next,
.sort_reverse,
.open_account_picker,
.toggle_overlay,
},
};
@ -397,6 +408,17 @@ pub const tab = struct {
const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active";
app.setStatus(msg);
},
.toggle_overlay => {
// Resolve the cursor row's symbol; pass empty
// string when not on a symbol row so toggleOverlay
// closes any open overlay.
const sym: []const u8 = blk: {
if (state.rows.items.len == 0) break :blk "";
if (state.cursor >= state.rows.items.len) break :blk "";
break :blk state.rows.items[state.cursor].symbol;
};
app.toggleOverlay(sym);
},
}
}

View file

@ -63,6 +63,14 @@ pub const Action = enum {
/// sector-level findings. The latter case is harmless the
/// active symbol just doesn't change anything per-tab.
select_symbol,
/// Toggle the symbol-info overlay popup. Open at cursor
/// row's symbol when closed; close when open and cursor is
/// on the same symbol; re-target when open and cursor is on
/// a different symbol. For findings rows, the symbol used is
/// the finding's `target` (which is the symbol for
/// per-symbol findings; harmless no-op label like "Technology"
/// for sector-level ones).
toggle_overlay,
};
/// Modal sub-state. `.normal` lets keystrokes flow through the
@ -96,10 +104,6 @@ pub const State = struct {
loaded: bool = false,
/// Computed view. Owned by State; freed in `deinit` and `reload`.
view: ?review_view.ReviewView = null,
/// Per-portfolio classification metadata (`metadata.srf`). Loaded
/// lazily on first activation, kept across reloads (cheap and
/// rarely-changing). Freed in `deinit`.
classification_map: ?zfin.classification.ClassificationMap = null,
/// Active sort field. Default `.sector` (asc) provides the
/// "grouped by sector with symbol-asc tiebreaker" entry state
/// see `views/review.sortRows` for why the sector column
@ -193,6 +197,9 @@ pub const meta: framework.TabMeta(Action) = .{
// Mirrors portfolio_tab so muscle memory transfers.
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .select_symbol, .key = .{ .codepoint = vaxis.Key.space } },
// Capital K: Vim-style "what is this" toggle. Same
// binding as portfolio_tab so the gesture transfers.
.{ .action = .toggle_overlay, .key = .{ .codepoint = 'K' } },
},
.action_labels = std.enums.EnumArray(Action, []const u8).init(.{
.sort_col_next = "Sort: next column",
@ -203,12 +210,14 @@ pub const meta: framework.TabMeta(Action) = .{
.unack = "Un-acknowledge finding",
.toggle_show_acked = "Toggle show acked findings",
.select_symbol = "Select symbol",
.toggle_overlay = "Show symbol details",
}),
.status_hints = &.{
.ack,
.unack,
.toggle_show_acked,
.select_symbol,
.toggle_overlay,
.sort_col_prev,
.sort_col_next,
},
@ -254,20 +263,19 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State);
/// Manual refresh: drops the cached view and re-builds. Also
/// clears the dividend cache (in case new dividends arrived) and
/// the shared `account_map` so accounts.srf gets re-read. The
/// classification_map persists it's per-portfolio, not
/// per-refresh. The journal is reloaded too in case the user
/// edited acknowledgments.srf out-of-band.
/// invalidates the shared `account_map` and `classification_map`
/// on PortfolioData so the next load re-reads `accounts.srf` /
/// `metadata.srf` from disk (the user may have edited either).
/// The journal is reloaded too in case the user edited
/// acknowledgments.srf out-of-band.
pub fn reload(state: *State, app: *App) !void {
if (state.view) |*v| v.deinit(app.allocator);
state.view = null;
if (state.findings_view) |*fv| fv.deinit(app.allocator);
state.findings_view = null;
state.loaded = false;
// Drop the cached account_map so the worker re-reads
// accounts.srf on the next accountMap() call.
app.portfolio.invalidateAccountMap();
app.portfolio.invalidateClassificationMap();
loadJournal(state, app);
loadData(state, app);
}
@ -306,6 +314,7 @@ pub const tab = struct {
rebuildFindingsView(state, app);
},
.select_symbol => selectSymbolAtCursor(state, app),
.toggle_overlay => toggleOverlayAtCursor(state, app),
}
}
@ -785,6 +794,31 @@ fn selectSymbolAtCursor(state: *State, app: *App) void {
app.setStatus(msg);
}
/// Resolve the cursor row's symbol and call `app.toggleOverlay`.
/// Empty cursor section empty symbol toggleOverlay closes
/// any open overlay. For findings rows, the target field doubles
/// as the symbol (per-symbol findings) or as a non-symbol label
/// (sector findings) the latter makes the overlay show "no
/// metadata.srf entry" for the bogus key, which is fine.
fn toggleOverlayAtCursor(state: *State, app: *App) void {
const symbol: []const u8 = switch (cursorSection(state)) {
.empty => "",
.holdings => blk: {
const view = state.view orelse break :blk "";
const local = cursorLocalIndex(state);
if (local >= view.rows.len) break :blk "";
break :blk view.rows[local].symbol;
},
.findings => blk: {
const fv = state.findings_view orelse break :blk "";
const local = cursorLocalIndex(state);
if (local >= fv.rows.len) break :blk "";
break :blk fv.rows[local].target;
},
};
app.toggleOverlay(symbol);
}
/// Free `state.note_fragments` items + the slice. Idempotent.
fn clearNoteFragments(state: *State, allocator: std.mem.Allocator) void {
for (state.note_fragments.items) |frag| allocator.free(frag);
@ -878,39 +912,23 @@ fn loadData(state: *State, app: *App) void {
const pf = app.portfolio.file orelse return;
const summary_ptr = if (app.portfolio.summary) |*s| s else return;
// Lazy-load classifications (per-tab; analysis_tab loads its own copy).
if (state.classification_map == null) {
if (app.anchorPath()) |ppath| {
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.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch {
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
defer app.allocator.free(file_data);
state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
app.setStatus("Error parsing metadata.srf");
return;
};
}
}
// Tier 1 accessors block on their respective worker futures.
// Review needs candles, dividends, and the account map; this
// is the first tab where all three workers' costs may show
// up as visible wait. Most users will hit portfolio first
// (where snapshots blocks if needed) before navigating to
// review, so the candles + dividends workers are usually
// done by the time we get here.
// Review needs candles, dividends, classification, and the
// account map; this is the first tab where all four workers'
// costs may show up as visible wait. Most users will hit
// portfolio first (where snapshots blocks if needed) before
// navigating to review, so the candles + dividends workers
// are usually done by the time we get here.
const candle_map = app.portfolio.candles() orelse {
app.setStatus("Portfolio data not loaded");
return;
};
const dividend_map = app.portfolio.dividends();
const acct_map_opt: ?zfin.analysis.AccountMap = if (app.portfolio.accountMap()) |amp| amp.* else null;
const cm_ptr = app.portfolio.classificationMap() orelse {
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
if (state.view) |*v| v.deinit(app.allocator);
state.view = review_view.buildReview(
@ -920,7 +938,7 @@ fn loadData(state: *State, app: *App) void {
candle_map,
dividend_map,
pf,
state.classification_map orelse return,
cm_ptr.*,
acct_map_opt,
app.today,
app.anchorPath() orelse "",
@ -1018,7 +1036,6 @@ pub fn deinitState(state: *State, allocator: std.mem.Allocator) void {
if (state.view) |*v| v.deinit(allocator);
if (state.findings_view) |*fv| fv.deinit(allocator);
if (state.journal) |*j| j.deinit();
if (state.classification_map) |*cm| cm.deinit();
for (state.note_fragments.items) |frag| allocator.free(frag);
state.note_fragments.deinit(allocator);
state.* = .{};
@ -2307,7 +2324,6 @@ test "deinitState: cleans up view (leak check)" {
var state: State = .{
.loaded = true,
.view = view,
.classification_map = null, // owned by caller in this test
.sort_field = .sector,
.sort_dir = .asc,
};