migrate projections tab to new framework
This commit is contained in:
parent
61dd86dc9a
commit
d4d09f73d2
3 changed files with 311 additions and 186 deletions
130
src/tui.zig
130
src/tui.zig
|
|
@ -288,6 +288,7 @@ pub const TabStates = struct {
|
|||
performance: performance_tab.State = .{},
|
||||
options: options_tab.State = .{},
|
||||
history: history_tab.State = .{},
|
||||
projections: projections_tab.State = .{},
|
||||
};
|
||||
|
||||
/// Comptime registry of all tab modules conforming to the
|
||||
|
|
@ -325,6 +326,7 @@ const tab_modules = .{
|
|||
.performance = performance_tab,
|
||||
.options = options_tab,
|
||||
.history = history_tab,
|
||||
.projections = projections_tab,
|
||||
};
|
||||
|
||||
comptime {
|
||||
|
|
@ -576,38 +578,7 @@ pub const App = struct {
|
|||
account_search_cursor: usize = 0, // cursor within search_matches
|
||||
|
||||
// History tab state lives in `self.states.history` (see TabStates).
|
||||
|
||||
// Projections tab state
|
||||
projections_loaded: bool = false,
|
||||
projections_disabled: bool = false,
|
||||
projections_config: @import("analytics/projections.zig").UserConfig = .{},
|
||||
projections_ctx: ?@import("views/projections.zig").ProjectionContext = null,
|
||||
projections_horizon_idx: usize = 0,
|
||||
projections_image_id: ?u32 = null, // Kitty graphics image ID for projection chart
|
||||
projections_image_width: u16 = 0,
|
||||
projections_image_height: u16 = 0,
|
||||
projections_chart_dirty: bool = true,
|
||||
projections_chart_visible: bool = true,
|
||||
projections_events_enabled: bool = true,
|
||||
projections_value_min: f64 = 0,
|
||||
projections_value_max: f64 = 0,
|
||||
/// When non-null, the projections tab renders against a historical
|
||||
/// snapshot instead of the live portfolio. Set via the `d` popup
|
||||
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
|
||||
/// earlier available snapshot. Cleared by `D` or by committing
|
||||
/// an empty / "live" input.
|
||||
projections_as_of: ?zfin.Date = null,
|
||||
/// When auto-snap kicked in, `projections_as_of` is the resolved
|
||||
/// snapshot date but `projections_as_of_requested` remembers what
|
||||
/// the user actually typed — surfaced in the tab header as a muted
|
||||
/// "(requested X; snapped to Y, N days earlier)" note.
|
||||
projections_as_of_requested: ?zfin.Date = null,
|
||||
/// When true, the projections chart overlays the realized
|
||||
/// portfolio trajectory (snapshots + imported_values) on top of
|
||||
/// the percentile bands. Toggled by the `o` keybind. Only
|
||||
/// meaningful when `projections_as_of` is set; the keybind
|
||||
/// flashes a status message and leaves this off otherwise.
|
||||
projections_overlay_actuals: bool = false,
|
||||
// Projections tab state lives in `self.states.projections`.
|
||||
|
||||
// Mouse wheel debounce for cursor-based tabs (portfolio, options).
|
||||
// Terminals often send multiple wheel events per physical tick.
|
||||
|
|
@ -1029,21 +1000,19 @@ pub const App = struct {
|
|||
self.input_len = 0;
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
self.projections_as_of = d;
|
||||
self.projections_as_of_requested = null;
|
||||
self.states.projections.as_of = d;
|
||||
self.states.projections.as_of_requested = null;
|
||||
var status_buf: [64]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set";
|
||||
self.setStatus(msg);
|
||||
} else {
|
||||
// `null` parse result = live.
|
||||
self.projections_as_of = null;
|
||||
self.projections_as_of_requested = null;
|
||||
self.states.projections.as_of = null;
|
||||
self.states.projections.as_of_requested = null;
|
||||
self.setStatus("As-of cleared — showing live");
|
||||
}
|
||||
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||
|
||||
self.mode = .normal;
|
||||
self.input_len = 0;
|
||||
|
|
@ -1302,13 +1271,11 @@ pub const App = struct {
|
|||
self.setStatus("Filter cleared: showing all accounts");
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
if (self.active_tab == .projections and self.projections_as_of != null) {
|
||||
self.projections_as_of = null;
|
||||
self.projections_as_of_requested = null;
|
||||
self.projections_overlay_actuals = false;
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
if (self.active_tab == .projections and self.states.projections.as_of != null) {
|
||||
self.states.projections.as_of = null;
|
||||
self.states.projections.as_of_requested = null;
|
||||
self.states.projections.overlay_actuals = false;
|
||||
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||
self.setStatus("As-of cleared — showing live");
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
|
@ -1516,24 +1483,7 @@ pub const App = struct {
|
|||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
if (self.active_tab == .projections) {
|
||||
if (self.projections_as_of == null) {
|
||||
self.setStatus("Overlay only available with --as-of (press d to set)");
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
self.projections_overlay_actuals = !self.projections_overlay_actuals;
|
||||
// Re-run loadData so the overlay section gets
|
||||
// built (or freed). The timeline load is the
|
||||
// expensive bit but it's rare — humans toggle
|
||||
// this maybe a few times per session.
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
self.projections_chart_dirty = true;
|
||||
if (self.projections_overlay_actuals) {
|
||||
self.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
|
||||
} else {
|
||||
self.setStatus("Overlay: OFF");
|
||||
}
|
||||
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.overlay_actuals);
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
|
|
@ -1555,20 +1505,13 @@ pub const App = struct {
|
|||
},
|
||||
.toggle_chart => {
|
||||
if (self.active_tab == .projections) {
|
||||
self.projections_chart_visible = !self.projections_chart_visible;
|
||||
self.projections_chart_dirty = true;
|
||||
self.scroll_offset = 0;
|
||||
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_chart);
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
.toggle_events => {
|
||||
if (self.active_tab == .projections) {
|
||||
self.projections_events_enabled = !self.projections_events_enabled;
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
const label = if (self.projections_events_enabled) "Events enabled" else "Events disabled";
|
||||
self.setStatus(label);
|
||||
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.toggle_events);
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
|
|
@ -1577,10 +1520,7 @@ pub const App = struct {
|
|||
// let the same key flow to their own handlers (none
|
||||
// currently bind plain 'd').
|
||||
if (self.active_tab == .projections) {
|
||||
self.mode = .date_input;
|
||||
self.input_len = 0;
|
||||
// No setStatus — drawStatusBar replaces the whole
|
||||
// line with the prompt + hint when mode is .date_input.
|
||||
projections_tab.tab.handleAction(&self.states.projections, self, projections_tab.Action.as_of_input);
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
|
|
@ -1781,8 +1721,7 @@ pub const App = struct {
|
|||
history_tab.tab.reload(&self.states.history, self) catch {};
|
||||
},
|
||||
.projections => {
|
||||
self.projections_loaded = false;
|
||||
projections_tab.freeLoaded(self);
|
||||
projections_tab.tab.reload(&self.states.projections, self) catch {};
|
||||
},
|
||||
}
|
||||
self.loadTabData();
|
||||
|
|
@ -1828,8 +1767,7 @@ pub const App = struct {
|
|||
history_tab.tab.activate(&self.states.history, self) catch {};
|
||||
},
|
||||
.projections => {
|
||||
if (self.projections_disabled) return;
|
||||
if (!self.projections_loaded) projections_tab.loadData(self);
|
||||
projections_tab.tab.activate(&self.states.projections, self) catch {};
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1884,7 +1822,7 @@ pub const App = struct {
|
|||
analysis_tab.tab.deinit(&self.states.analysis, self);
|
||||
self.portfolio.deinit(self.allocator);
|
||||
history_tab.tab.deinit(&self.states.history, self);
|
||||
projections_tab.freeLoaded(self);
|
||||
projections_tab.tab.deinit(&self.states.projections, self);
|
||||
quote_tab.tab.deinit(&self.states.quote, self);
|
||||
}
|
||||
|
||||
|
|
@ -1961,15 +1899,12 @@ pub const App = struct {
|
|||
}
|
||||
|
||||
/// Whether the given tab should be treated as disabled in
|
||||
/// the current App context. Migrated tabs are consulted via
|
||||
/// their framework-contract `isDisabled` hook; unmigrated tabs
|
||||
/// (projections) still use bespoke `_disabled` flags set during
|
||||
/// App init. The unmigrated branch goes away when projections
|
||||
/// adopts the framework — the `_disabled` field will be deleted
|
||||
/// alongside the inline check here.
|
||||
/// the current App context. All migrated tabs are consulted
|
||||
/// via their framework-contract `isDisabled` hook. (Portfolio
|
||||
/// is the only remaining unmigrated tab; it has no disabled
|
||||
/// predicate today.)
|
||||
fn isDisabled(self: *App, t: Tab) bool {
|
||||
return self.appPredicate(t, "isDisabled") or
|
||||
(t == .projections and self.projections_disabled);
|
||||
return self.appPredicate(t, "isDisabled");
|
||||
}
|
||||
|
||||
fn isSymbolSelected(self: *App) bool {
|
||||
|
|
@ -2016,7 +1951,7 @@ pub const App = struct {
|
|||
const start = @min(self.scroll_offset, if (lines.len > 0) lines.len - 1 else 0);
|
||||
try self.drawStyledContent(ctx.arena, buf, width, height, lines[start..]);
|
||||
},
|
||||
.projections => try projections_tab.drawContent(self, ctx, buf, width, height),
|
||||
.projections => try projections_tab.drawContent(&self.states.projections, self, ctx, buf, width, height),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2539,15 +2474,6 @@ pub fn run(
|
|||
app_inst.active_tab = .quote;
|
||||
}
|
||||
|
||||
// Disable projections tab when no portfolio is loaded.
|
||||
// Analysis derives the same condition via its `isDisabled`
|
||||
// method (no field write needed) — the predicate is computed
|
||||
// from `app.portfolio.file == null` directly. History does the
|
||||
// same — see `history_tab.tab.isDisabled`.
|
||||
if (app_inst.portfolio.file == null) {
|
||||
app_inst.projections_disabled = true;
|
||||
}
|
||||
|
||||
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||
// This runs while the terminal is still in normal mode so output is visible.
|
||||
if (app_inst.portfolio.file) |pf| {
|
||||
|
|
@ -2621,9 +2547,9 @@ pub fn run(
|
|||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||
app_inst.states.quote.chart.image_id = null;
|
||||
}
|
||||
if (app_inst.projections_image_id) |id| {
|
||||
if (app_inst.states.projections.image_id) |id| {
|
||||
vx_app.vx.freeImage(vx_app.tty.writer(), id);
|
||||
app_inst.projections_image_id = null;
|
||||
app_inst.states.projections.image_id = null;
|
||||
}
|
||||
}
|
||||
try vx_app.run(app_inst.widget(), .{});
|
||||
|
|
|
|||
|
|
@ -1297,12 +1297,15 @@ pub fn reloadPortfolioFile(app: *App) void {
|
|||
analysis_tab.tab.activate(&app.states.analysis, app) catch {};
|
||||
}
|
||||
|
||||
// Invalidate projections data — projections.srf may have changed
|
||||
projections_tab.freeLoaded(app);
|
||||
app.projections_loaded = false;
|
||||
app.projections_disabled = false;
|
||||
// Invalidate projections data — projections.srf may have changed.
|
||||
// Always drop the cached context so a stale render doesn't leak;
|
||||
// re-fetch only if the user is actively looking at projections.
|
||||
// (When not active, the next `activate` lazily re-fetches.)
|
||||
if (app.active_tab == .projections) {
|
||||
projections_tab.loadData(app);
|
||||
projections_tab.tab.reload(&app.states.projections, app) catch {};
|
||||
} else {
|
||||
projections_tab.freeLoaded(&app.states.projections, app);
|
||||
app.states.projections.loaded = false;
|
||||
}
|
||||
|
||||
if (missing > 0) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
//!
|
||||
//! ## As-of mode
|
||||
//!
|
||||
//! When `app.projections_as_of` is non-null, the tab renders against a
|
||||
//! When `state.as_of` is non-null, the tab renders against a
|
||||
//! historical snapshot instead of the live portfolio, using
|
||||
//! `view.loadProjectionContextAsOf`. The user toggles this via the `d`
|
||||
//! keybind (date popup) or `D` (return to live). Auto-snaps to the
|
||||
|
|
@ -34,14 +34,210 @@ const performance = @import("../analytics/performance.zig");
|
|||
const valuation = @import("../analytics/valuation.zig");
|
||||
const view = @import("../views/projections.zig");
|
||||
const history = @import("../history.zig");
|
||||
const framework = @import("tab_framework.zig");
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
||||
// ── Tab-local action enum ─────────────────────────────────────
|
||||
//
|
||||
// Projections tab keybinds (today routed through the legacy
|
||||
// global `keybinds.Action` variants and the central tui.zig
|
||||
// switch — `o` → sort_reverse, `c` → toggle_chart, `b` →
|
||||
// toggle_events, `d` → projections_as_of_input. When scoped
|
||||
// keymaps land (TODO step 3), these become genuinely tab-local
|
||||
// and the global enum variants for them disappear.
|
||||
|
||||
pub const Action = enum {
|
||||
/// Toggle the as-of-anchored actuals overlay on the projection
|
||||
/// chart. No-op (with status hint) when not in as-of mode.
|
||||
/// Today bound to `o` via the global `sort_reverse` action.
|
||||
overlay_actuals,
|
||||
/// Show / hide the percentile-band chart. Toggles
|
||||
/// `state.chart_visible`; doesn't reload data.
|
||||
toggle_chart,
|
||||
/// Enable / disable simulated lifecycle events (RMDs, lump-sum
|
||||
/// withdrawals). Forces a reload because the simulation engine
|
||||
/// re-runs with the new flag.
|
||||
toggle_events,
|
||||
/// Open the as-of date input mini-popup. Mode transition is
|
||||
/// handled by the App; the popup commit / clear path lives in
|
||||
/// tui.zig and calls `loadData` on this tab.
|
||||
as_of_input,
|
||||
};
|
||||
|
||||
// ── Tab-private state ─────────────────────────────────────────
|
||||
|
||||
pub const State = struct {
|
||||
/// Whether `activate` has populated `ctx` (or set up
|
||||
/// disabled state). Distinct from `ctx != null` because failed
|
||||
/// loads still mark loaded.
|
||||
loaded: bool = false,
|
||||
/// User-tunable inputs to the projection engine (annual
|
||||
/// contribution, target spending, retirement target percentile,
|
||||
/// etc.). Driven by annotations on the portfolio file.
|
||||
config: @import("../analytics/projections.zig").UserConfig = .{},
|
||||
/// Loaded projection context: bands, withdrawal tables,
|
||||
/// horizon configs, optional overlay actuals. Owned by State;
|
||||
/// freed via `freeLoaded`.
|
||||
ctx: ?@import("../views/projections.zig").ProjectionContext = null,
|
||||
/// Currently-focused horizon row in the terminal-value table.
|
||||
/// (Reserved for future expansion; not consumed today.)
|
||||
horizon_idx: usize = 0,
|
||||
/// Kitty graphics image id for the percentile-band chart, when
|
||||
/// using the Kitty path. Null when no image is currently
|
||||
/// transmitted to the terminal.
|
||||
image_id: ?u32 = null,
|
||||
/// Pixel dimensions of the most-recently-transmitted image
|
||||
/// (used to detect resize and re-render).
|
||||
image_width: u16 = 0,
|
||||
image_height: u16 = 0,
|
||||
/// True when the chart needs re-rendering on the next draw.
|
||||
/// Set by `freeLoaded`, by toggle_chart, by overlay_actuals
|
||||
/// changes, etc.
|
||||
chart_dirty: bool = true,
|
||||
/// Whether the percentile-band chart is shown. Toggled by
|
||||
/// `toggle_chart`. When false, the tab renders text-only with
|
||||
/// scroll (the full report).
|
||||
chart_visible: bool = true,
|
||||
/// Whether simulated lifecycle events (RMDs, lump-sum
|
||||
/// withdrawals) are included in the projection. Toggled by
|
||||
/// `toggle_events`; flipping forces a reload.
|
||||
events_enabled: bool = true,
|
||||
/// Y-axis bounds last used for the chart — informational, not
|
||||
/// load-bearing in dispatch.
|
||||
value_min: f64 = 0,
|
||||
value_max: f64 = 0,
|
||||
/// When non-null, the projections tab renders against a historical
|
||||
/// snapshot instead of the live portfolio. Set via the `d` popup
|
||||
/// (parsed by `cli.parseAsOfDate`) and auto-snapped to the nearest
|
||||
/// earlier available snapshot. Cleared by Esc on the projections
|
||||
/// tab when set, or by committing an empty / "live" input.
|
||||
as_of: ?zfin.Date = null,
|
||||
/// When auto-snap kicked in, `as_of` is the resolved snapshot
|
||||
/// date but `as_of_requested` remembers what the user actually
|
||||
/// typed — surfaced in the tab header as a muted "(requested X;
|
||||
/// snapped to Y, N days earlier)" note.
|
||||
as_of_requested: ?zfin.Date = null,
|
||||
/// When true, the projections chart overlays the realized
|
||||
/// portfolio trajectory (snapshots + imported_values) on top of
|
||||
/// the percentile bands. Toggled by `overlay_actuals`. Only
|
||||
/// meaningful when `as_of` is set; the action flashes a status
|
||||
/// message and leaves this off otherwise.
|
||||
overlay_actuals: bool = false,
|
||||
};
|
||||
|
||||
// ── Tab framework contract ────────────────────────────────────
|
||||
|
||||
pub const tab = struct {
|
||||
pub const ActionT = Action;
|
||||
pub const StateT = State;
|
||||
|
||||
pub const default_bindings: []const framework.TabBinding(Action) = &.{
|
||||
// Today's keybinds are in the global keymap (sort_reverse,
|
||||
// toggle_chart, toggle_events, projections_as_of_input).
|
||||
// These per-tab declarations become authoritative when
|
||||
// scoped keymaps land.
|
||||
.{ .action = .overlay_actuals, .key = .{ .codepoint = 'o' } },
|
||||
.{ .action = .toggle_chart, .key = .{ .codepoint = 'c' } },
|
||||
.{ .action = .toggle_events, .key = .{ .codepoint = 'b' } },
|
||||
.{ .action = .as_of_input, .key = .{ .codepoint = 'd' } },
|
||||
};
|
||||
|
||||
pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{
|
||||
.overlay_actuals = "Toggle actuals overlay",
|
||||
.toggle_chart = "Toggle chart visibility",
|
||||
.toggle_events = "Toggle lifecycle events",
|
||||
.as_of_input = "Set as-of date",
|
||||
});
|
||||
|
||||
pub const status_hints: []const Action = &.{
|
||||
.toggle_chart,
|
||||
.toggle_events,
|
||||
.as_of_input,
|
||||
};
|
||||
|
||||
pub fn init(state: *State, app: *App) !void {
|
||||
_ = app;
|
||||
state.* = .{};
|
||||
}
|
||||
|
||||
pub fn deinit(state: *State, app: *App) void {
|
||||
freeLoaded(state, app);
|
||||
state.* = .{};
|
||||
}
|
||||
|
||||
pub fn activate(state: *State, app: *App) !void {
|
||||
if (state.loaded) return;
|
||||
loadData(state, app);
|
||||
}
|
||||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
|
||||
pub fn reload(state: *State, app: *App) !void {
|
||||
state.loaded = false;
|
||||
freeLoaded(state, app);
|
||||
loadData(state, app);
|
||||
}
|
||||
|
||||
pub const tick = framework.noopTick(State);
|
||||
|
||||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||
switch (action) {
|
||||
.overlay_actuals => {
|
||||
if (state.as_of == null) {
|
||||
app.setStatus("Overlay only available with --as-of (press d to set)");
|
||||
return;
|
||||
}
|
||||
state.overlay_actuals = !state.overlay_actuals;
|
||||
// Re-run loadData so the overlay section gets built
|
||||
// (or freed). The timeline load is the expensive
|
||||
// bit but it's rare — humans toggle this maybe a
|
||||
// few times per session.
|
||||
freeLoaded(state, app);
|
||||
state.loaded = false;
|
||||
loadData(state, app);
|
||||
state.chart_dirty = true;
|
||||
if (state.overlay_actuals) {
|
||||
app.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
|
||||
} else {
|
||||
app.setStatus("Overlay: OFF");
|
||||
}
|
||||
},
|
||||
.toggle_chart => {
|
||||
state.chart_visible = !state.chart_visible;
|
||||
state.chart_dirty = true;
|
||||
app.scroll_offset = 0;
|
||||
},
|
||||
.toggle_events => {
|
||||
state.events_enabled = !state.events_enabled;
|
||||
freeLoaded(state, app);
|
||||
state.loaded = false;
|
||||
loadData(state, app);
|
||||
const label = if (state.events_enabled) "Events enabled" else "Events disabled";
|
||||
app.setStatus(label);
|
||||
},
|
||||
.as_of_input => {
|
||||
app.mode = .date_input;
|
||||
app.input_len = 0;
|
||||
// No setStatus — drawStatusBar replaces the whole
|
||||
// line with the prompt + hint when mode is .date_input.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Projections requires a loaded portfolio (the simulation
|
||||
/// engine reads lots / allocations from `app.portfolio`). Same
|
||||
/// predicate as analysis_tab and history_tab.
|
||||
pub fn isDisabled(app: *App) bool {
|
||||
return app.portfolio.file == null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(app: *App) void {
|
||||
app.projections_loaded = true;
|
||||
freeLoaded(app);
|
||||
pub fn loadData(state: *State, app: *App) void {
|
||||
state.loaded = true;
|
||||
freeLoaded(state, app);
|
||||
|
||||
const portfolio_path = app.portfolio_path orelse {
|
||||
app.setStatus("Projections tab requires a loaded portfolio");
|
||||
|
|
@ -61,25 +257,25 @@ pub fn loadData(app: *App) void {
|
|||
// message explaining why, and fall through to the live path so
|
||||
// the tab still shows something rather than going blank.
|
||||
as_of: {
|
||||
const requested_date = app.projections_as_of orelse break :as_of;
|
||||
const requested_date = state.as_of orelse break :as_of;
|
||||
|
||||
const resolution = resolveAsOf(app, portfolio_path, requested_date) orelse {
|
||||
const resolution = resolveAsOf(state, app, portfolio_path, requested_date) orelse {
|
||||
// `setStatus` already called by resolveAsOf.
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
const actual_date = resolution.actual;
|
||||
app.projections_as_of = actual_date;
|
||||
state.as_of = actual_date;
|
||||
// Preserve requested for the header note; clear if it matches actual.
|
||||
if (actual_date.eql(requested_date)) {
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of_requested = null;
|
||||
}
|
||||
|
||||
const hist_dir = history.deriveHistoryDir(app.allocator, portfolio_path) catch {
|
||||
app.setStatus("Failed to derive history dir — showing live");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
defer app.allocator.free(hist_dir);
|
||||
|
|
@ -88,8 +284,8 @@ pub fn loadData(app: *App) void {
|
|||
.snapshot => snap: {
|
||||
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
|
||||
app.setStatus("Failed to load snapshot — showing live");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
defer loaded.deinit(app.allocator);
|
||||
|
|
@ -101,11 +297,11 @@ pub fn loadData(app: *App) void {
|
|||
&loaded.snap,
|
||||
actual_date,
|
||||
app.svc,
|
||||
app.projections_events_enabled,
|
||||
state.events_enabled,
|
||||
) catch {
|
||||
app.setStatus("Failed to compute as-of projections — showing live");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
},
|
||||
|
|
@ -116,14 +312,14 @@ pub fn loadData(app: *App) void {
|
|||
// up-front into `app.portfolio.summary`.
|
||||
const summary = app.portfolio.summary orelse {
|
||||
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
const portfolio = app.portfolio.file orelse {
|
||||
app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
|
||||
|
|
@ -138,11 +334,11 @@ pub fn loadData(app: *App) void {
|
|||
resolution.liquid,
|
||||
actual_date,
|
||||
app.svc,
|
||||
app.projections_events_enabled,
|
||||
state.events_enabled,
|
||||
) catch {
|
||||
app.setStatus("Failed to compute as-of projections — showing live");
|
||||
app.projections_as_of = null;
|
||||
app.projections_as_of_requested = null;
|
||||
state.as_of = null;
|
||||
state.as_of_requested = null;
|
||||
break :as_of;
|
||||
};
|
||||
},
|
||||
|
|
@ -152,7 +348,7 @@ pub fn loadData(app: *App) void {
|
|||
// Attach the actuals overlay if the toggle is on. Failures
|
||||
// here are non-fatal — the chart still renders without the
|
||||
// overlay; the toggle stays on so the user knows the intent.
|
||||
if (app.projections_overlay_actuals) {
|
||||
if (state.overlay_actuals) {
|
||||
if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| {
|
||||
ctx_with_overlay.overlay_actuals = ov;
|
||||
} else |_| {
|
||||
|
|
@ -162,7 +358,7 @@ pub fn loadData(app: *App) void {
|
|||
}
|
||||
}
|
||||
|
||||
app.projections_ctx = ctx_with_overlay;
|
||||
state.ctx = ctx_with_overlay;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -184,14 +380,14 @@ pub fn loadData(app: *App) void {
|
|||
portfolio.totalCash(app.today),
|
||||
portfolio.totalCdFaceValue(app.today),
|
||||
app.svc,
|
||||
app.projections_events_enabled,
|
||||
state.events_enabled,
|
||||
app.today,
|
||||
) catch {
|
||||
app.setStatus("Failed to compute projections");
|
||||
return;
|
||||
};
|
||||
|
||||
app.projections_ctx = ctx;
|
||||
state.ctx = ctx;
|
||||
}
|
||||
|
||||
/// Resolve the user's requested as-of date against the portfolio's
|
||||
|
|
@ -202,7 +398,7 @@ pub fn loadData(app: *App) void {
|
|||
/// Thin adapter over `history.resolveAsOfDate` — the shared pure
|
||||
/// resolver owns exact-then-fallback logic; this wrapper maps its
|
||||
/// errors to user-visible status-bar messages and handles the arena.
|
||||
fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
|
||||
fn resolveAsOf(state: *State, app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
|
||||
var arena_state = std.heap.ArenaAllocator.init(app.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
|
@ -231,7 +427,7 @@ fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?his
|
|||
|
||||
if (!resolved.exact) {
|
||||
// Remember the original request for the muted header note.
|
||||
app.projections_as_of_requested = requested;
|
||||
state.as_of_requested = requested;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
|
@ -246,8 +442,8 @@ fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !
|
|||
return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today);
|
||||
}
|
||||
|
||||
pub fn freeLoaded(app: *App) void {
|
||||
if (app.projections_ctx) |*ctx| {
|
||||
pub fn freeLoaded(state: *State, app: *App) void {
|
||||
if (state.ctx) |*ctx| {
|
||||
app.allocator.free(ctx.data.withdrawals);
|
||||
for (ctx.data.bands) |b| {
|
||||
if (b) |slice| app.allocator.free(slice);
|
||||
|
|
@ -256,14 +452,14 @@ pub fn freeLoaded(app: *App) void {
|
|||
if (ctx.earliest) |er| app.allocator.free(er);
|
||||
if (ctx.overlay_actuals) |*ov| ov.deinit();
|
||||
}
|
||||
app.projections_ctx = null;
|
||||
state.ctx = null;
|
||||
// Mark projection chart as dirty so it re-renders on next draw
|
||||
app.projections_chart_dirty = true;
|
||||
state.chart_dirty = true;
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
pub fn drawContent(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const arena = ctx.arena;
|
||||
|
||||
// Determine whether to use Kitty graphics
|
||||
|
|
@ -274,7 +470,7 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
|
|||
};
|
||||
|
||||
// Need bands data for the chart
|
||||
const has_bands = if (app.projections_ctx) |pctx| blk: {
|
||||
const has_bands = if (state.ctx) |pctx| blk: {
|
||||
const horizons = pctx.config.getHorizons();
|
||||
if (horizons.len == 0) break :blk false;
|
||||
const last_idx = horizons.len - 1;
|
||||
|
|
@ -284,27 +480,27 @@ pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, wi
|
|||
break :blk false;
|
||||
} else false;
|
||||
|
||||
if (use_kitty and has_bands and app.projections_chart_visible) {
|
||||
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
||||
try drawWithScroll(app, arena, buf, width, height);
|
||||
if (use_kitty and has_bands and state.chart_visible) {
|
||||
drawWithKittyChart(state, app, ctx, buf, width, height) catch {
|
||||
try drawWithScroll(state, app, arena, buf, width, height);
|
||||
};
|
||||
} else {
|
||||
try drawWithScroll(app, arena, buf, width, height);
|
||||
try drawWithScroll(state, app, arena, buf, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render styled lines with scroll_offset applied.
|
||||
fn drawWithScroll(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const all_lines = try buildStyledLines(app, arena);
|
||||
fn drawWithScroll(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const all_lines = try buildStyledLines(state, app, arena);
|
||||
const start = @min(app.scroll_offset, if (all_lines.len > 0) all_lines.len - 1 else 0);
|
||||
try app.drawStyledContent(arena, buf, width, height, all_lines[start..]);
|
||||
}
|
||||
|
||||
/// Draw projections tab using Kitty graphics protocol for the percentile band chart.
|
||||
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
fn drawWithKittyChart(state: *State, app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const arena = ctx.arena;
|
||||
const th = app.theme;
|
||||
const pctx = app.projections_ctx orelse return;
|
||||
const pctx = state.ctx orelse return;
|
||||
const config = pctx.config;
|
||||
const horizons = config.getHorizons();
|
||||
const last_idx = horizons.len - 1;
|
||||
|
|
@ -313,7 +509,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
|
||||
// Build text header (benchmark comparison + allocation note)
|
||||
var header_lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
try buildHeaderSection(app, arena, &header_lines, pctx);
|
||||
try buildHeaderSection(state, app, arena, &header_lines, pctx);
|
||||
|
||||
// Chart title
|
||||
try header_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
|
@ -338,7 +534,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
const chart_rows = height -| header_rows -| footer_reserve;
|
||||
if (chart_rows < 6) {
|
||||
// Not enough space for chart — fall back to text-only with scroll
|
||||
try drawWithScroll(app, arena, buf, width, height);
|
||||
try drawWithScroll(state, app, arena, buf, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -356,13 +552,13 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
const capped_h = @min(px_h, app.chart_config.max_height);
|
||||
|
||||
// Render or reuse cached image
|
||||
if (app.projections_chart_dirty) {
|
||||
if (state.chart_dirty) {
|
||||
// Free old image
|
||||
if (app.projections_image_id) |old_id| {
|
||||
if (state.image_id) |old_id| {
|
||||
if (app.vx_app) |va| {
|
||||
va.vx.freeImage(va.tty.writer(), old_id);
|
||||
}
|
||||
app.projections_image_id = null;
|
||||
state.image_id = null;
|
||||
}
|
||||
|
||||
if (app.vx_app) |va| {
|
||||
|
|
@ -377,8 +573,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
// dirty redraw because the overlay is at most ~12 years
|
||||
// of weekly data (~600 points).
|
||||
const overlay_input: ?projection_chart.ActualsOverlay = blk: {
|
||||
if (!app.projections_overlay_actuals) break :blk null;
|
||||
const ctx_data = app.projections_ctx orelse break :blk null;
|
||||
if (!state.overlay_actuals) break :blk null;
|
||||
const ctx_data = state.ctx orelse break :blk null;
|
||||
const ov = ctx_data.overlay_actuals orelse break :blk null;
|
||||
const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
|
||||
for (ov.points, 0..) |p, idx| {
|
||||
|
|
@ -397,7 +593,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
th,
|
||||
overlay_input,
|
||||
) catch {
|
||||
app.projections_chart_dirty = false;
|
||||
state.chart_dirty = false;
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(chart_result.rgb_data);
|
||||
|
|
@ -405,7 +601,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
// Base64-encode and transmit
|
||||
const base64_enc = std.base64.standard.Encoder;
|
||||
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
||||
app.projections_chart_dirty = false;
|
||||
state.chart_dirty = false;
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(b64_buf);
|
||||
|
|
@ -418,21 +614,21 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
chart_result.height,
|
||||
.rgb,
|
||||
) catch {
|
||||
app.projections_chart_dirty = false;
|
||||
state.chart_dirty = false;
|
||||
return;
|
||||
};
|
||||
|
||||
app.projections_image_id = img.id;
|
||||
app.projections_image_width = @intCast(chart_cols);
|
||||
app.projections_image_height = chart_rows;
|
||||
app.projections_value_min = chart_result.value_min;
|
||||
app.projections_value_max = chart_result.value_max;
|
||||
app.projections_chart_dirty = false;
|
||||
state.image_id = img.id;
|
||||
state.image_width = @intCast(chart_cols);
|
||||
state.image_height = chart_rows;
|
||||
state.value_min = chart_result.value_min;
|
||||
state.value_max = chart_result.value_max;
|
||||
state.chart_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Place the image in the cell buffer
|
||||
if (app.projections_image_id) |img_id| {
|
||||
if (state.image_id) |img_id| {
|
||||
const chart_row_start: usize = header_rows;
|
||||
const chart_col_start: usize = 1;
|
||||
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
||||
|
|
@ -444,8 +640,8 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
.img_id = img_id,
|
||||
.options = .{
|
||||
.size = .{
|
||||
.rows = app.projections_image_height,
|
||||
.cols = app.projections_image_width,
|
||||
.rows = state.image_height,
|
||||
.cols = state.image_width,
|
||||
},
|
||||
.scale = .contain,
|
||||
},
|
||||
|
|
@ -454,23 +650,23 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
}
|
||||
|
||||
// Axis labels (dollar values on the right side)
|
||||
const img_rows = app.projections_image_height;
|
||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.projections_image_width) + 1;
|
||||
const img_rows = state.image_height;
|
||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, state.image_width) + 1;
|
||||
const label_style = th.mutedStyle();
|
||||
|
||||
if (label_col + 10 <= width and img_rows >= 4 and app.projections_value_max > app.projections_value_min) {
|
||||
if (label_col + 10 <= width and img_rows >= 4 and state.value_max > state.value_min) {
|
||||
// Label band boundaries at the right edge, in priority order:
|
||||
// p10 and p90 (extremes, always kept), then p50 (median), then p25/p75 (lowest priority).
|
||||
const last_band = bands[bands.len - 1];
|
||||
const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 };
|
||||
const val_range = app.projections_value_max - app.projections_value_min;
|
||||
const val_range = state.value_max - state.value_min;
|
||||
const rows_f = @as(f64, @floatFromInt(img_rows -| 1));
|
||||
|
||||
var placed_rows: [5]usize = undefined;
|
||||
var placed_count: usize = 0;
|
||||
|
||||
for (label_values) |val| {
|
||||
const norm = (val - app.projections_value_min) / val_range;
|
||||
const norm = (val - state.value_min) / val_range;
|
||||
const row_f = @as(f64, @floatFromInt(chart_row_start)) + (1.0 - norm) * rows_f;
|
||||
const row: usize = @intFromFloat(@round(row_f));
|
||||
if (row >= height) continue;
|
||||
|
|
@ -536,7 +732,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
}
|
||||
|
||||
// Render footer (terminal values + withdrawal table) below the chart
|
||||
const footer_start_row = header_rows + app.projections_image_height + 1; // +1 for axis row
|
||||
const footer_start_row = header_rows + state.image_height + 1; // +1 for axis row
|
||||
if (footer_start_row + 4 < height) {
|
||||
const footer_slice = try footer_lines.toOwnedSlice(arena);
|
||||
const footer_buf_start = footer_start_row * @as(usize, width);
|
||||
|
|
@ -549,7 +745,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
|
|||
}
|
||||
|
||||
/// Build the header section (benchmark comparison table + allocation note).
|
||||
fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
|
||||
fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines: *std.ArrayListUnmanaged(StyledLine), pctx: view.ProjectionContext) !void {
|
||||
const th = app.theme;
|
||||
const comparison = pctx.comparison;
|
||||
const config = pctx.config;
|
||||
|
|
@ -624,7 +820,7 @@ fn buildHeaderSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
|||
// historical snapshot date when one is configured so the
|
||||
// promoted date and earliest-retirement grid anchor on the
|
||||
// same reference point as the rest of the as-of-mode display.
|
||||
const ref_date = app.projections_as_of orelse app.today;
|
||||
const ref_date = state.as_of orelse app.today;
|
||||
try appendAccumulationBlocks(lines, arena, th, pctx, ref_date);
|
||||
}
|
||||
|
||||
|
|
@ -884,13 +1080,13 @@ fn appendAccumulationBlocks(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
const ctx = app.projections_ctx orelse {
|
||||
const ctx = state.ctx orelse {
|
||||
try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
};
|
||||
|
|
@ -903,8 +1099,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
|||
// historical snapshot. Muted header note so it doesn't compete
|
||||
// with the main content. If the user asked for a date that had no
|
||||
// exact snapshot, a second muted line explains the auto-snap.
|
||||
if (app.projections_as_of) |actual| {
|
||||
const source_label: []const u8 = if (app.projections_ctx) |c| switch (c.as_of_source) {
|
||||
if (state.as_of) |actual| {
|
||||
const source_label: []const u8 = if (state.ctx) |c| switch (c.as_of_source) {
|
||||
.snapshot => "snapshot",
|
||||
.imported => "imported",
|
||||
.live => "live",
|
||||
|
|
@ -912,7 +1108,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
|||
const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label });
|
||||
try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
|
||||
|
||||
if (app.projections_as_of_requested) |requested| {
|
||||
if (state.as_of_requested) |requested| {
|
||||
if (!requested.eql(actual)) {
|
||||
const diff = requested.days - actual.days;
|
||||
const note = try std.fmt.allocPrint(
|
||||
|
|
@ -923,7 +1119,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
|||
try lines.append(arena, .{ .text = note, .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
if (app.projections_ctx) |c| {
|
||||
if (state.ctx) |c| {
|
||||
if (c.as_of_source == .imported) {
|
||||
try lines.append(arena, .{
|
||||
.text = " (bands use today's allocation scaled to the imported liquid total)",
|
||||
|
|
@ -931,7 +1127,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
|||
});
|
||||
}
|
||||
}
|
||||
if (app.projections_overlay_actuals) {
|
||||
if (state.overlay_actuals) {
|
||||
const ov_note = try std.fmt.allocPrint(
|
||||
arena,
|
||||
" Overlay: actuals from {f} · tracks trajectory, not SWR validity",
|
||||
|
|
@ -1014,7 +1210,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine
|
|||
// historical snapshot date when one is configured so the
|
||||
// promoted date and earliest-retirement grid anchor on the
|
||||
// same reference point as the rest of the as-of-mode display.
|
||||
const ref_date = app.projections_as_of orelse app.today;
|
||||
const ref_date = state.as_of orelse app.today;
|
||||
try appendAccumulationBlocks(&lines, arena, th, ctx, ref_date);
|
||||
|
||||
// Braille chart: median portfolio value over the longest horizon
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue