dedup the snapshot loading function

This commit is contained in:
Emil Lerch 2026-04-23 01:25:00 -07:00
parent 1ef8ffd10d
commit b3ebc3f986
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 105 additions and 56 deletions

View file

@ -180,29 +180,25 @@ fn runPortfolio(
color: bool,
out: *std.Io.Writer,
) !void {
// Derive history/ from the portfolio path's directory.
const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse ".";
const history_dir = try std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
defer allocator.free(history_dir);
var loaded = try history_io.loadHistoryDir(allocator, history_dir);
defer loaded.deinit();
// Derive history/ + load snapshots + build series in one shot.
// Both --rebuild-rollup (wants raw snapshots) and the main render
// path (wants the series) share this, so the combined loader gives
// us both without re-parsing.
var tl = try history_io.loadTimeline(allocator, portfolio_path);
defer tl.deinit();
if (opts.rebuild_rollup) {
try rebuildRollup(allocator, history_dir, loaded.snapshots, out);
try rebuildRollup(allocator, tl.history_dir, tl.loaded.snapshots, out);
return;
}
if (loaded.snapshots.len == 0) {
try out.print("No portfolio snapshots found in {s}.\n", .{history_dir});
if (tl.loaded.snapshots.len == 0) {
try out.print("No portfolio snapshots found in {s}.\n", .{tl.history_dir});
try out.print("Run `zfin snapshot` to capture the first one.\n", .{});
return;
}
const series = try timeline.buildSeries(allocator, loaded.snapshots);
defer series.deinit();
const filtered = try timeline.filterByDate(allocator, series.points, opts.since, opts.until);
const filtered = try timeline.filterByDate(allocator, tl.series.points, opts.since, opts.until);
defer allocator.free(filtered);
if (filtered.len == 0) {

View file

@ -26,6 +26,7 @@ const std = @import("std");
const srf = @import("srf");
const snapshot_mod = @import("models/snapshot.zig");
const Date = @import("models/date.zig").Date;
const timeline = @import("analytics/timeline.zig");
pub const Error = error{
/// The file didn't open a `#!srfv1` directive or couldn't be
@ -207,6 +208,73 @@ pub fn loadHistoryDir(
};
}
/// Derive `<dirname(portfolio_path)>/history` and return the joined
/// path (caller-owned). Thin helper, but exposed so CLI and TUI agree
/// on the convention (history/ is always a sibling of portfolio.srf).
pub fn deriveHistoryDir(
allocator: std.mem.Allocator,
portfolio_path: []const u8,
) ![]u8 {
const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse ".";
return std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
}
/// Result of `loadTimeline` bundles the raw snapshot collection and
/// the derived timeline series so callers can reach either without
/// re-parsing.
///
/// `series.points` is sorted ascending by date; `loaded.snapshots` is
/// in filesystem enumeration order. Both are kept alive together
/// `series.points` references dates that live inside `loaded`'s
/// snapshot rows, and the callers may want `loaded.snapshots` directly
/// for non-timeline uses (e.g. rollup building).
pub const LoadedTimeline = struct {
loaded: LoadedHistory,
series: timeline.TimelineSeries,
/// Directory we loaded from, caller-owned. Carried through for
/// callers that want to print diagnostics or locate sibling files
/// (rollup.srf, etc.).
history_dir: []u8,
allocator: std.mem.Allocator,
pub fn deinit(self: *LoadedTimeline) void {
self.series.deinit();
self.loaded.deinit();
self.allocator.free(self.history_dir);
}
};
/// End-to-end snapshot timeline loader: derives history/, reads every
/// `*-portfolio.srf` file, and builds the sorted timeline series. The
/// single entry point used by both the CLI `zfin history` command and
/// the TUI history tab their earlier copies had subtle divergences
/// (different dir-split logic, slightly different empty-state ordering)
/// that a shared helper rules out.
///
/// Returns `loaded.snapshots.len == 0` on an empty history dir rather
/// than erroring; callers check and produce their own "no snapshots"
/// message. Parse failures on individual files are logged to stderr by
/// `loadHistoryDir` and the offending file is skipped.
pub fn loadTimeline(
allocator: std.mem.Allocator,
portfolio_path: []const u8,
) !LoadedTimeline {
const history_dir = try deriveHistoryDir(allocator, portfolio_path);
errdefer allocator.free(history_dir);
var loaded = try loadHistoryDir(allocator, history_dir);
errdefer loaded.deinit();
const series = try timeline.buildSeries(allocator, loaded.snapshots);
return .{
.loaded = loaded,
.series = series,
.history_dir = history_dir,
.allocator = allocator,
};
}
// Tests
const testing = std.testing;

View file

@ -377,9 +377,11 @@ pub const App = struct {
// History tab state
history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it)
history_load_result: ?history_io.LoadedHistory = null,
history_series: ?timeline_mod.TimelineSeries = null,
history_metric: timeline_mod.Metric = .net_worth,
history_timeline: ?history_io.LoadedTimeline = null,
// Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view.
history_metric: timeline_mod.Metric = .liquid,
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
// Terminals often send multiple wheel events per physical tick.

View file

@ -41,49 +41,27 @@ pub fn loadData(app: *App) void {
return;
};
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx else {
app.setStatus("Cannot derive history/ from portfolio path");
return;
};
const portfolio_dir = portfolio_path[0..dir_end];
const history_dir = std.fs.path.join(app.allocator, &.{ portfolio_dir, "history" }) catch return;
defer app.allocator.free(history_dir);
var loaded = history_io.loadHistoryDir(app.allocator, history_dir) catch {
// Shared path with the `zfin history` CLI command derives
// history/, loads every snapshot, and builds the timeline series.
// If history/ is missing or all files are malformed, we surface a
// status message and leave `app.history_timeline` null; the
// renderer handles the empty case.
app.history_timeline = history_io.loadTimeline(app.allocator, portfolio_path) catch {
app.setStatus("Failed to read history/ directory");
return;
};
errdefer loaded.deinit();
if (loaded.snapshots.len == 0) {
loaded.deinit();
if (app.history_timeline.?.loaded.snapshots.len == 0) {
freeLoaded(app);
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
return;
}
var series = timeline.buildSeries(app.allocator, loaded.snapshots) catch {
loaded.deinit();
app.setStatus("Failed to build timeline series");
return;
};
// Commit
app.history_load_result = loaded;
app.history_series = series;
_ = &series; // keep mutable reference (not needed once committed)
}
/// Free both the snapshot bytes and the timeline series. Caller must
/// null out the fields after this if they want to allow re-loading.
/// Release the loaded timeline (if any).
pub fn freeLoaded(app: *App) void {
if (app.history_series) |s| {
s.deinit();
app.history_series = null;
}
if (app.history_load_result) |*lh| {
lh.deinit();
app.history_load_result = null;
if (app.history_timeline) |*tl| {
tl.deinit();
app.history_timeline = null;
}
}
@ -99,7 +77,8 @@ pub fn cycleMetric(app: *App) void {
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderHistoryLines(arena, app.theme, app.history_series, app.history_metric);
const series: ?timeline.TimelineSeries = if (app.history_timeline) |tl| tl.series else null;
return renderHistoryLines(arena, app.theme, series, app.history_metric);
}
/// Pure renderer no App dependency. Builds the styled lines from a
@ -177,13 +156,17 @@ pub fn renderHistoryLines(
.style = th.mutedStyle(),
});
// Show up to max_table_rows most recent, but preserve oldest-first
// ordering for deltas to accumulate intuitively.
// Show up to max_table_rows most recent. Render newest-first so
// the latest snapshot sits directly under the column headers,
// matching typical "recent items" lists and saving the eye a scroll.
const start = if (points.len > max_table_rows) points.len - max_table_rows else 0;
const first = points[0]; // deltas still measured from series start
for (points[start..]) |p| {
const text = try fmtTableRow(arena, p, first);
const window = points[start..];
var i: usize = window.len;
while (i > 0) {
i -= 1;
const text = try fmtTableRow(arena, window[i], first);
try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
}