dedup the snapshot loading function
This commit is contained in:
parent
d473179815
commit
9acf3115db
4 changed files with 105 additions and 56 deletions
|
|
@ -180,29 +180,25 @@ fn runPortfolio(
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
// Derive history/ from the portfolio path's directory.
|
// Derive history/ + load snapshots + build series in one shot.
|
||||||
const portfolio_dir = std.fs.path.dirname(portfolio_path) orelse ".";
|
// Both --rebuild-rollup (wants raw snapshots) and the main render
|
||||||
const history_dir = try std.fs.path.join(allocator, &.{ portfolio_dir, "history" });
|
// path (wants the series) share this, so the combined loader gives
|
||||||
defer allocator.free(history_dir);
|
// us both without re-parsing.
|
||||||
|
var tl = try history_io.loadTimeline(allocator, portfolio_path);
|
||||||
var loaded = try history_io.loadHistoryDir(allocator, history_dir);
|
defer tl.deinit();
|
||||||
defer loaded.deinit();
|
|
||||||
|
|
||||||
if (opts.rebuild_rollup) {
|
if (opts.rebuild_rollup) {
|
||||||
try rebuildRollup(allocator, history_dir, loaded.snapshots, out);
|
try rebuildRollup(allocator, tl.history_dir, tl.loaded.snapshots, out);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loaded.snapshots.len == 0) {
|
if (tl.loaded.snapshots.len == 0) {
|
||||||
try out.print("No portfolio snapshots found in {s}.\n", .{history_dir});
|
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", .{});
|
try out.print("Run `zfin snapshot` to capture the first one.\n", .{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const series = try timeline.buildSeries(allocator, loaded.snapshots);
|
const filtered = try timeline.filterByDate(allocator, tl.series.points, opts.since, opts.until);
|
||||||
defer series.deinit();
|
|
||||||
|
|
||||||
const filtered = try timeline.filterByDate(allocator, series.points, opts.since, opts.until);
|
|
||||||
defer allocator.free(filtered);
|
defer allocator.free(filtered);
|
||||||
|
|
||||||
if (filtered.len == 0) {
|
if (filtered.len == 0) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const std = @import("std");
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
const snapshot_mod = @import("models/snapshot.zig");
|
const snapshot_mod = @import("models/snapshot.zig");
|
||||||
const Date = @import("models/date.zig").Date;
|
const Date = @import("models/date.zig").Date;
|
||||||
|
const timeline = @import("analytics/timeline.zig");
|
||||||
|
|
||||||
pub const Error = error{
|
pub const Error = error{
|
||||||
/// The file didn't open a `#!srfv1` directive or couldn't be
|
/// 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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
|
||||||
|
|
@ -377,9 +377,11 @@ pub const App = struct {
|
||||||
// History tab state
|
// History tab state
|
||||||
history_loaded: bool = false,
|
history_loaded: bool = false,
|
||||||
history_disabled: bool = false, // true when no portfolio path (history requires it)
|
history_disabled: bool = false, // true when no portfolio path (history requires it)
|
||||||
history_load_result: ?history_io.LoadedHistory = null,
|
history_timeline: ?history_io.LoadedTimeline = null,
|
||||||
history_series: ?timeline_mod.TimelineSeries = null,
|
// Default to `.liquid` — that's the metric most worth watching
|
||||||
history_metric: timeline_mod.Metric = .net_worth,
|
// 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).
|
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
||||||
// Terminals often send multiple wheel events per physical tick.
|
// Terminals often send multiple wheel events per physical tick.
|
||||||
|
|
|
||||||
|
|
@ -41,49 +41,27 @@ pub fn loadData(app: *App) void {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx else {
|
// Shared path with the `zfin history` CLI command — derives
|
||||||
app.setStatus("Cannot derive history/ from portfolio path");
|
// history/, loads every snapshot, and builds the timeline series.
|
||||||
return;
|
// If history/ is missing or all files are malformed, we surface a
|
||||||
};
|
// status message and leave `app.history_timeline` null; the
|
||||||
const portfolio_dir = portfolio_path[0..dir_end];
|
// renderer handles the empty case.
|
||||||
|
app.history_timeline = history_io.loadTimeline(app.allocator, portfolio_path) catch {
|
||||||
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 {
|
|
||||||
app.setStatus("Failed to read history/ directory");
|
app.setStatus("Failed to read history/ directory");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
errdefer loaded.deinit();
|
|
||||||
|
|
||||||
if (loaded.snapshots.len == 0) {
|
if (app.history_timeline.?.loaded.snapshots.len == 0) {
|
||||||
loaded.deinit();
|
freeLoaded(app);
|
||||||
app.setStatus("No snapshots in history/ (run: zfin snapshot)");
|
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
|
/// Release the loaded timeline (if any).
|
||||||
/// null out the fields after this if they want to allow re-loading.
|
|
||||||
pub fn freeLoaded(app: *App) void {
|
pub fn freeLoaded(app: *App) void {
|
||||||
if (app.history_series) |s| {
|
if (app.history_timeline) |*tl| {
|
||||||
s.deinit();
|
tl.deinit();
|
||||||
app.history_series = null;
|
app.history_timeline = null;
|
||||||
}
|
|
||||||
if (app.history_load_result) |*lh| {
|
|
||||||
lh.deinit();
|
|
||||||
app.history_load_result = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +77,8 @@ pub fn cycleMetric(app: *App) void {
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
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
|
/// Pure renderer — no App dependency. Builds the styled lines from a
|
||||||
|
|
@ -177,13 +156,17 @@ pub fn renderHistoryLines(
|
||||||
.style = th.mutedStyle(),
|
.style = th.mutedStyle(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show up to max_table_rows most recent, but preserve oldest-first
|
// Show up to max_table_rows most recent. Render newest-first so
|
||||||
// ordering for deltas to accumulate intuitively.
|
// 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 start = if (points.len > max_table_rows) points.len - max_table_rows else 0;
|
||||||
const first = points[0]; // deltas still measured from series start
|
const first = points[0]; // deltas still measured from series start
|
||||||
|
|
||||||
for (points[start..]) |p| {
|
const window = points[start..];
|
||||||
const text = try fmtTableRow(arena, p, first);
|
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() });
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue