diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index bdf0130..f4b7770 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -447,6 +447,33 @@ pub fn invalidateClassificationMap(self: *PortfolioData) void { self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) }); } +/// Prime ONLY the `metadata.srf` classification map for `paths_in`, +/// without the full `load()` (no price fetch, no summary, no +/// candle/dividend/account workers). Spawns the same +/// `classificationMapWorker` that `load()` uses, so a subsequent +/// `classificationMap()` resolves curated security names identically. +/// +/// Used by the TUI's explicit-symbol launch (`zfin AAPL`), which skips +/// the portfolio load entirely but still wants the quote tab (and the +/// 'K' overlay) to show the `metadata.srf` security name the way the CLI +/// `quote` command does. No-op when `paths_in` is empty or a portfolio +/// is already loaded (a full `load()` populates the map itself). +pub fn primeClassificationMap(self: *PortfolioData, paths_in: []const []const u8) void { + if (paths_in.len == 0 or self.paths.len != 0) return; + + // Dupe the anchor paths into the per-load arena so `anchorPath()` + // (and the worker's metadata.srf derivation) outlive `paths_in`. + const arena_alloc = self.allocator(); + const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return; + for (paths_in, 0..) |p, i| { + paths_dup[i] = arena_alloc.dupe(u8, p) catch return; + } + self.paths = paths_dup; + + if (self.classification_map_future) |*f| _ = f.cancel(self.io); + 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 { @@ -984,6 +1011,43 @@ test "PortfolioData.cancelLoad: idempotent on idle state" { try testing.expect(pd.classification_map_data == null); } +test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" { + var svc: DataService = .{ + .allocator = testing.allocator, + .io = testing.io, + .config = .{ .cache_dir = "./.tmp/zfin-pd-prime-cache" }, + }; + var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc }); + defer pd.deinit(); + + // Explicit-symbol launch shape: nothing loaded yet, so the map is + // null - exactly the state that left the TUI quote tab nameless. + try testing.expectEqual(@as(usize, 0), pd.paths.len); + try testing.expect(pd.classification_map_future == null); + try testing.expect(pd.classificationMap() == null); + + // Empty paths: no-op (still nothing loaded). + pd.primeClassificationMap(&.{}); + try testing.expectEqual(@as(usize, 0), pd.paths.len); + try testing.expect(pd.classification_map_future == null); + + // A real anchor sets the path and spawns the SAME worker `load()` + // uses, so a later `classificationMap()` reads metadata.srf and + // resolves the curated name the way the CLI does. + pd.primeClassificationMap(&.{"./.tmp/zfin-pd-prime-test/portfolio.srf"}); + try testing.expectEqual(@as(usize, 1), pd.paths.len); + try testing.expect(pd.classification_map_future != null); + try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?); + + // Already primed: a second call must not clobber the loaded paths. + pd.primeClassificationMap(&.{"./.tmp/other/portfolio.srf"}); + try testing.expectEqual(@as(usize, 1), pd.paths.len); + try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?); + + // Drain the spawned worker so teardown leaves no dangling future. + _ = pd.classificationMap(); +} + test "PortfolioData.candles: returns null after cancelLoad with no data" { var svc: DataService = .{ .allocator = testing.allocator, diff --git a/src/tui.zig b/src/tui.zig index 42cb869..ee44f9d 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2556,6 +2556,17 @@ pub fn run( if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| { resolved_pf_paths = rp; } else |_| {} + } else { + // Explicit-symbol launch (e.g. `zfin AAPL`) skips the full + // portfolio load below, but the quote tab (and the 'K' overlay) + // still want the curated security name from metadata.srf - same + // as the CLI `quote` command. Prime just the classification map + // (no price fetch) so classificationMap() resolves the name. + if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| { + var rp_owned = rp; + defer rp_owned.deinit(); + app_inst.portfolio.primeClassificationMap(rp_owned.paths); + } else |_| {} } var resolved_wl: ?zfin.Config.ResolvedPath = null;