dedupe/centralize and add history view
All checks were successful
Generic zig build / build (push) Successful in 1m45s
Generic zig build / deploy (push) Successful in 16s

This commit is contained in:
Emil Lerch 2026-04-23 23:06:02 -07:00
parent 2326154d81
commit 11a282e2db
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 372 additions and 9 deletions

View file

@ -36,7 +36,7 @@ const atomic = @import("../atomic.zig");
const timeline = @import("../analytics/timeline.zig");
const history_io = @import("../history.zig");
const snapshot_model = @import("../models/snapshot.zig");
const view = @import("../view/history.zig");
const view = @import("../views/history.zig");
const fmt = cli.fmt;
const Date = @import("../models/date.zig").Date;
@ -340,11 +340,16 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
var pbuf: [16]u8 = undefined;
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
// Whole row colored by sign. `missing`/`zero` use muted so the
// n/a and $0.00 rows don't visually shout green or red.
switch (cells.sign) {
.positive, .negative => try cli.setGainLoss(out, color, if (cells.sign == .positive) 1.0 else -1.0),
.zero, .missing => try cli.setFg(out, color, cli.CLR_MUTED),
// Whole row colored by style intent. `muted` covers both
// zero and missing-anchor rows neither deserves a
// green/red shout.
switch (cells.style) {
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
// `normal` is unreachable in the windows block (build
// never emits it); no-op keeps the switch exhaustive.
.normal => {},
}
try out.print(" {s:<12} {s:>18} {s:>10}", .{

View file

@ -29,7 +29,7 @@ const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const history_io = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
const view = @import("../view/history.zig");
const view = @import("../views/history.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
@ -246,10 +246,14 @@ fn appendWindowsBlock(
" {s:<12} {s:>18} {s:>10}",
.{ cells.label, cells.delta_str, cells.pct_str },
);
const style: vaxis.Cell.Style = switch (cells.sign) {
const style: vaxis.Cell.Style = switch (cells.style) {
.positive => th.positiveStyle(),
.negative => th.negativeStyle(),
.zero, .missing => th.mutedStyle(),
.muted => th.mutedStyle(),
// `normal` is unreachable in the windows block (build
// never emits it); fall back to content style to keep
// the switch exhaustive.
.normal => th.contentStyle(),
};
try lines.append(arena, .{ .text = text, .style = style });
}

354
src/views/history.zig Normal file
View file

@ -0,0 +1,354 @@
//! `src/views/history.zig` view models for the portfolio history UX.
//!
//! Sits alongside `views/portfolio_sections.zig` in the views layer,
//! which owns renderer-agnostic display data consumed by both CLI and
//! TUI. Column widths, format strings, computed values, and style
//! decisions are defined here; renderers are thin adapters that map
//! `StyleIntent` to platform-specific styles and emit pre-formatted
//! text.
//!
//! Contract: no IO, no writer, no ANSI, no `vaxis.Cell.Style`. Functions
//! consume `timeline` types and format primitives from `src/format.zig`,
//! and return formatted strings or structured data.
//!
//! Why this layer exists: the CLI and TUI render the same sections
//! (windows block, recent-snapshots table) from the same data. Before
//! this module, both reimplemented the formatting in parallel and
//! drifted (column widths didn't match, labels differed, row styling
//! diverged). Centralizing the row-cell layout here guarantees parity.
const std = @import("std");
const timeline = @import("../analytics/timeline.zig");
const fmt = @import("../format.zig");
const StyleIntent = fmt.StyleIntent;
// Column widths (shared by CLI + TUI)
/// Width of the leftmost label column in the windows block.
/// Fits "10 years" + padding. `{s:<12}` (left-aligned).
pub const windows_label_width: usize = 12;
/// Width of the Δ (signed money) column. Fits worst-case like
/// `"+$99,999,999.99"` (15 chars) with slack. Right-aligned.
pub const windows_delta_width: usize = 18;
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
pub const windows_pct_width: usize = 10;
/// Width of the date column in the recent-snapshots table.
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
pub const table_date_width: usize = 10;
/// Width of each `"$value (±Δ)"` composite cell in the table. Fits
/// worst-case eight-figure totals with deltas. Right-aligned (padded
/// to this width by `fmtValueDeltaCell`).
pub const table_cell_width: usize = 28;
// Windows block row
/// One row's worth of pre-formatted cells for the windows block.
/// Strings reference caller-owned buffers passed into
/// `buildWindowRowCells`; do not outlive them.
///
/// `style` is a semantic intent the renderer maps it to the
/// platform's actual color. Zero-delta and missing-anchor rows both
/// resolve to `.muted` because neither deserves a green/red shout in
/// practice; no caller today needs to distinguish them.
pub const WindowRowCells = struct {
label: []const u8,
delta_str: []const u8,
pct_str: []const u8,
style: StyleIntent,
};
/// Render a WindowStat into displayable cells. `delta_buf` and
/// `pct_buf` are caller-owned stack buffers the returned strings
/// borrow from they must outlive the returned struct.
///
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
/// style. Positive/negative deltas map to `.positive` / `.negative`.
///
/// The pct column never shows a trailing-whitespace oddity: sign and
/// digits sit flush (`"+0.41%"`, not `"+ 0.41%"`). Column alignment
/// is the caller's job via `windows_pct_width`.
pub fn buildWindowRowCells(
row: timeline.WindowStat,
delta_buf: *[32]u8,
pct_buf: *[16]u8,
) WindowRowCells {
const style: StyleIntent = blk: {
const d = row.delta_abs orelse break :blk .muted;
if (d > 0) break :blk .positive;
if (d < 0) break :blk .negative;
break :blk .muted; // zero delta muted (same as missing)
};
const delta_str: []const u8 = if (row.delta_abs) |d|
fmtSignedMoneyBuf(delta_buf, d)
else
"n/a";
const pct_str: []const u8 = if (row.delta_pct) |p|
fmtSignedPercentBuf(pct_buf, p)
else
// delta_abs missing OR start_value was 0 (division by zero).
// Both read as "pct undefined" for the user.
"n/a";
return .{
.label = row.label,
.delta_str = delta_str,
.pct_str = pct_str,
.style = style,
};
}
/// Format a signed dollar amount: `"+$1,234.56"`, `"-$1,234.56"`,
/// `"$0.00"`. Returns a slice of `buf`.
///
/// Separate from `fmt.fmtMoneyAbs` (which omits the sign) because the
/// windows block's Δ column needs the leading sign to distinguish
/// gains from losses at a glance.
pub fn fmtSignedMoneyBuf(buf: *[32]u8, value: f64) []const u8 {
const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else "";
var tmp: [24]u8 = undefined;
const abs_str = fmt.fmtMoneyAbs(&tmp, value);
return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str }) catch "?";
}
/// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`.
/// Input is a ratio (0.0041 "+0.41%"). Returns a slice of `buf`.
///
/// Sign sits flush against the digit no internal whitespace
/// padding. Right-alignment in the column is the caller's job.
pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
const pct = ratio * 100.0;
const prefix: []const u8 = if (pct > 0) "+" else if (pct < 0) "-" else "";
return std.fmt.bufPrint(buf, "{s}{d:.2}%", .{ prefix, @abs(pct) }) catch "?";
}
// Recent-snapshots table cell
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
/// table, right-padded (i.e. left-space-padded) to `width` characters.
/// Writes into `buf`; returns a slice covering the full width.
///
/// `delta_opt = null` (first row) renders as `"$value (—)"` the
/// em-dash signals "no prior row to compare against" without wasting
/// a Δ column.
///
/// Overflow behavior: if the natural cell is wider than `width`, the
/// raw cell is returned un-truncated. Callers that need strict width
/// can measure the returned slice and decide.
pub fn fmtValueDeltaCell(
buf: []u8,
value: f64,
delta_opt: ?f64,
width: usize,
) []const u8 {
var val_buf: [24]u8 = undefined;
var delta_buf: [32]u8 = undefined;
const val_str = fmt.fmtMoneyAbs(&val_buf, value);
const d_str: []const u8 = if (delta_opt) |d|
fmtSignedMoneyBuf(&delta_buf, d)
else
"";
// Build the natural cell into a scratch buffer first so we know
// its length; then left-pad into `buf`.
var natural_buf: [96]u8 = undefined;
const natural = std.fmt.bufPrint(&natural_buf, "{s} ({s})", .{ val_str, d_str }) catch return "?";
if (natural.len >= width) {
// No room to pad; just copy as much as fits.
const n = @min(natural.len, buf.len);
@memcpy(buf[0..n], natural[0..n]);
return buf[0..n];
}
const pad = width - natural.len;
if (pad + natural.len > buf.len) {
// Buf too small to hold the padded cell; return the natural
// slice so callers at least see the content.
const n = @min(natural.len, buf.len);
@memcpy(buf[0..n], natural[0..n]);
return buf[0..n];
}
@memset(buf[0..pad], ' ');
@memcpy(buf[pad .. pad + natural.len], natural);
return buf[0 .. pad + natural.len];
}
// Resolution label
/// Format the resolution label shown in the recent-snapshots title:
/// `"(auto - daily)"` when `override` is null (auto; resolved to
/// `effective`)
/// `"(daily)"` / `"(weekly)"` / `"(monthly)"` when the user has
/// explicitly picked a resolution.
///
/// Returns a slice of `buf`.
pub fn fmtResolutionLabel(
buf: []u8,
override: ?timeline.Resolution,
effective: timeline.Resolution,
) []const u8 {
return if (override == null)
std.fmt.bufPrint(buf, "(auto - {s})", .{effective.label()}) catch "?"
else
std.fmt.bufPrint(buf, "({s})", .{effective.label()}) catch "?";
}
// Tests
const testing = std.testing;
const Date = @import("../models/date.zig").Date;
fn makeWindowStat(
period: ?@import("../analytics/valuation.zig").HistoricalPeriod,
label: []const u8,
start_value: ?f64,
end_value: f64,
) timeline.WindowStat {
const delta_abs: ?f64 = if (start_value) |s| end_value - s else null;
const delta_pct: ?f64 = blk: {
const s = start_value orelse break :blk null;
if (s == 0) break :blk null;
break :blk (end_value - s) / s;
};
return .{
.period = period,
.label = label,
.short_label = label,
.anchor_date = if (start_value != null) Date.fromYmd(2026, 4, 17) else null,
.start_value = start_value,
.end_value = end_value,
.delta_abs = delta_abs,
.delta_pct = delta_pct,
};
}
// buildWindowRowCells
test "buildWindowRowCells: positive delta" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
const row = makeWindowStat(null, "1 day", 1000, 1100);
const cells = buildWindowRowCells(row, &db, &pb);
try testing.expectEqualStrings("1 day", cells.label);
try testing.expectEqualStrings("+$100.00", cells.delta_str);
try testing.expectEqualStrings("+10.00%", cells.pct_str);
try testing.expectEqual(StyleIntent.positive, cells.style);
}
test "buildWindowRowCells: negative delta" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
const row = makeWindowStat(null, "1 day", 1000, 900);
const cells = buildWindowRowCells(row, &db, &pb);
try testing.expectEqualStrings("-$100.00", cells.delta_str);
try testing.expectEqualStrings("-10.00%", cells.pct_str);
try testing.expectEqual(StyleIntent.negative, cells.style);
}
test "buildWindowRowCells: zero delta renders muted" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
const row = makeWindowStat(null, "1 day", 1000, 1000);
const cells = buildWindowRowCells(row, &db, &pb);
try testing.expectEqualStrings("$0.00", cells.delta_str);
try testing.expectEqualStrings("0.00%", cells.pct_str);
try testing.expectEqual(StyleIntent.muted, cells.style);
}
test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
const row = makeWindowStat(null, "1 year", null, 1100);
const cells = buildWindowRowCells(row, &db, &pb);
try testing.expectEqualStrings("n/a", cells.delta_str);
try testing.expectEqualStrings("n/a", cells.pct_str);
try testing.expectEqual(StyleIntent.muted, cells.style);
}
test "buildWindowRowCells: zero start_value → pct n/a, delta present" {
var db: [32]u8 = undefined;
var pb: [16]u8 = undefined;
const row = makeWindowStat(null, "All-time", 0, 100);
const cells = buildWindowRowCells(row, &db, &pb);
// Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
try testing.expectEqualStrings("+$100.00", cells.delta_str);
try testing.expectEqualStrings("n/a", cells.pct_str);
try testing.expectEqual(StyleIntent.positive, cells.style);
}
// fmtSignedMoneyBuf
test "fmtSignedMoneyBuf: signs + zero + thousands" {
var buf: [32]u8 = undefined;
try testing.expectEqualStrings("+$1,234.56", fmtSignedMoneyBuf(&buf, 1234.56));
try testing.expectEqualStrings("-$1,234.56", fmtSignedMoneyBuf(&buf, -1234.56));
try testing.expectEqualStrings("$0.00", fmtSignedMoneyBuf(&buf, 0));
}
// fmtSignedPercentBuf
test "fmtSignedPercentBuf: signs + zero + flush digits" {
var buf: [16]u8 = undefined;
try testing.expectEqualStrings("+0.41%", fmtSignedPercentBuf(&buf, 0.0041));
try testing.expectEqualStrings("-1.07%", fmtSignedPercentBuf(&buf, -0.0107));
try testing.expectEqualStrings("0.00%", fmtSignedPercentBuf(&buf, 0));
// Pct larger than 100% still renders cleanly.
try testing.expectEqualStrings("+123.45%", fmtSignedPercentBuf(&buf, 1.2345));
}
// fmtValueDeltaCell
test "fmtValueDeltaCell: padded to width, positive delta" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, 50, 28);
try testing.expectEqual(@as(usize, 28), s.len);
// Content ends with the raw cell; leading spaces pad.
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (+$50.00)"));
// Starts with at least one space (padding present).
try testing.expectEqual(@as(u8, ' '), s[0]);
}
test "fmtValueDeltaCell: null delta renders em-dash" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, null, 28);
try testing.expectEqual(@as(usize, 28), s.len);
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (—)"));
}
test "fmtValueDeltaCell: negative delta" {
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, -50, 28);
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (-$50.00)"));
}
test "fmtValueDeltaCell: overflow returns un-truncated natural cell" {
// 10-char width is too small for "$1,000.00 (+$50.00)" (19 chars).
var buf: [64]u8 = undefined;
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
// No padding applied (natural len >= width); full content returned.
try testing.expect(s.len >= 19);
try testing.expect(std.mem.startsWith(u8, s, "$1,000.00"));
}
// fmtResolutionLabel
test "fmtResolutionLabel: auto reveals the effective resolution" {
var buf: [32]u8 = undefined;
try testing.expectEqualStrings("(auto - daily)", fmtResolutionLabel(&buf, null, .daily));
try testing.expectEqualStrings("(auto - weekly)", fmtResolutionLabel(&buf, null, .weekly));
try testing.expectEqualStrings("(auto - monthly)", fmtResolutionLabel(&buf, null, .monthly));
}
test "fmtResolutionLabel: explicit override hides the 'auto' prefix" {
var buf: [32]u8 = undefined;
try testing.expectEqualStrings("(daily)", fmtResolutionLabel(&buf, .daily, .daily));
try testing.expectEqualStrings("(weekly)", fmtResolutionLabel(&buf, .weekly, .weekly));
try testing.expectEqualStrings("(monthly)", fmtResolutionLabel(&buf, .monthly, .monthly));
}