dedupe/centralize and add history view
This commit is contained in:
parent
2326154d81
commit
11a282e2db
3 changed files with 372 additions and 9 deletions
|
|
@ -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}", .{
|
||||
|
|
|
|||
|
|
@ -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
354
src/views/history.zig
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue