diff --git a/src/commands/history.zig b/src/commands/history.zig index 3f7f37b..e781d00 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -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}", .{ diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 8e950f6..d3b1c6e 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -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 }); } diff --git a/src/views/history.zig b/src/views/history.zig new file mode 100644 index 0000000..d8ab020 --- /dev/null +++ b/src/views/history.zig @@ -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)); +}